Skip to content

Commit a4d581c

Browse files
improvement(canonical): backfill for canonical modes on config changes (#3447)
* improvement(canonical): backfill for canonical modes on config changes * persist data changes to db
1 parent f1efc59 commit a4d581c

File tree

3 files changed

+265
-5
lines changed

3 files changed

+265
-5
lines changed

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

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { describe, expect, it, vi } from 'vitest'
55
import type { BlockState } from '@/stores/workflows/workflow/types'
6-
import { migrateSubblockIds } from './subblock-migrations'
6+
7+
vi.unmock('@/blocks/registry')
8+
9+
import { backfillCanonicalModes, migrateSubblockIds } from './subblock-migrations'
710

811
function makeBlock(overrides: Partial<BlockState> & { type: string }): BlockState {
912
return {
@@ -181,3 +184,185 @@ describe('migrateSubblockIds', () => {
181184
expect(migrated).toBe(false)
182185
})
183186
})
187+
188+
describe('backfillCanonicalModes', () => {
189+
it('should add missing canonicalModes entry for knowledge block with basic value', () => {
190+
const input: Record<string, BlockState> = {
191+
b1: makeBlock({
192+
type: 'knowledge',
193+
data: {},
194+
subBlocks: {
195+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
196+
knowledgeBaseSelector: {
197+
id: 'knowledgeBaseSelector',
198+
type: 'knowledge-base-selector',
199+
value: 'kb-uuid',
200+
},
201+
},
202+
}),
203+
}
204+
205+
const { blocks, migrated } = backfillCanonicalModes(input)
206+
207+
expect(migrated).toBe(true)
208+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
209+
expect(modes.knowledgeBaseId).toBe('basic')
210+
})
211+
212+
it('should resolve to advanced when only the advanced value is set', () => {
213+
const input: Record<string, BlockState> = {
214+
b1: makeBlock({
215+
type: 'knowledge',
216+
data: {},
217+
subBlocks: {
218+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
219+
manualKnowledgeBaseId: {
220+
id: 'manualKnowledgeBaseId',
221+
type: 'short-input',
222+
value: 'kb-uuid-manual',
223+
},
224+
},
225+
}),
226+
}
227+
228+
const { blocks, migrated } = backfillCanonicalModes(input)
229+
230+
expect(migrated).toBe(true)
231+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
232+
expect(modes.knowledgeBaseId).toBe('advanced')
233+
})
234+
235+
it('should not overwrite existing canonicalModes entries', () => {
236+
const input: Record<string, BlockState> = {
237+
b1: makeBlock({
238+
type: 'knowledge',
239+
data: { canonicalModes: { knowledgeBaseId: 'advanced' } },
240+
subBlocks: {
241+
knowledgeBaseSelector: {
242+
id: 'knowledgeBaseSelector',
243+
type: 'knowledge-base-selector',
244+
value: 'kb-uuid',
245+
},
246+
},
247+
}),
248+
}
249+
250+
const { blocks, migrated } = backfillCanonicalModes(input)
251+
252+
expect(migrated).toBe(false)
253+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
254+
expect(modes.knowledgeBaseId).toBe('advanced')
255+
})
256+
257+
it('should skip blocks with no canonical pairs in their config', () => {
258+
const input: Record<string, BlockState> = {
259+
b1: makeBlock({
260+
type: 'function',
261+
data: {},
262+
subBlocks: {
263+
code: { id: 'code', type: 'code', value: '' },
264+
},
265+
}),
266+
}
267+
268+
const { migrated } = backfillCanonicalModes(input)
269+
270+
expect(migrated).toBe(false)
271+
})
272+
273+
it('should not mutate the input blocks', () => {
274+
const input: Record<string, BlockState> = {
275+
b1: makeBlock({
276+
type: 'knowledge',
277+
data: {},
278+
subBlocks: {
279+
knowledgeBaseSelector: {
280+
id: 'knowledgeBaseSelector',
281+
type: 'knowledge-base-selector',
282+
value: 'kb-uuid',
283+
},
284+
},
285+
}),
286+
}
287+
288+
const { blocks } = backfillCanonicalModes(input)
289+
290+
expect(input.b1.data?.canonicalModes).toBeUndefined()
291+
expect((blocks.b1.data?.canonicalModes as Record<string, string>).knowledgeBaseId).toBe('basic')
292+
expect(blocks).not.toBe(input)
293+
})
294+
295+
it('should resolve correctly when existing field became the basic variant', () => {
296+
const input: Record<string, BlockState> = {
297+
b1: makeBlock({
298+
type: 'knowledge',
299+
data: {},
300+
subBlocks: {
301+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
302+
knowledgeBaseSelector: {
303+
id: 'knowledgeBaseSelector',
304+
type: 'knowledge-base-selector',
305+
value: 'kb-uuid',
306+
},
307+
manualKnowledgeBaseId: {
308+
id: 'manualKnowledgeBaseId',
309+
type: 'short-input',
310+
value: '',
311+
},
312+
},
313+
}),
314+
}
315+
316+
const { blocks, migrated } = backfillCanonicalModes(input)
317+
318+
expect(migrated).toBe(true)
319+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
320+
expect(modes.knowledgeBaseId).toBe('basic')
321+
})
322+
323+
it('should resolve correctly when existing field became the advanced variant', () => {
324+
const input: Record<string, BlockState> = {
325+
b1: makeBlock({
326+
type: 'knowledge',
327+
data: {},
328+
subBlocks: {
329+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
330+
knowledgeBaseSelector: {
331+
id: 'knowledgeBaseSelector',
332+
type: 'knowledge-base-selector',
333+
value: '',
334+
},
335+
manualKnowledgeBaseId: {
336+
id: 'manualKnowledgeBaseId',
337+
type: 'short-input',
338+
value: 'manually-entered-kb-id',
339+
},
340+
},
341+
}),
342+
}
343+
344+
const { blocks, migrated } = backfillCanonicalModes(input)
345+
346+
expect(migrated).toBe(true)
347+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
348+
expect(modes.knowledgeBaseId).toBe('advanced')
349+
})
350+
351+
it('should default to basic when neither value is set', () => {
352+
const input: Record<string, BlockState> = {
353+
b1: makeBlock({
354+
type: 'knowledge',
355+
data: {},
356+
subBlocks: {
357+
operation: { id: 'operation', type: 'dropdown', value: 'search' },
358+
},
359+
}),
360+
}
361+
362+
const { blocks, migrated } = backfillCanonicalModes(input)
363+
364+
expect(migrated).toBe(true)
365+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
366+
expect(modes.knowledgeBaseId).toBe('basic')
367+
})
368+
})

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import { createLogger } from '@sim/logger'
2+
import {
3+
buildCanonicalIndex,
4+
buildSubBlockValues,
5+
isCanonicalPair,
6+
resolveCanonicalMode,
7+
} from '@/lib/workflows/subblocks/visibility'
8+
import { getBlock } from '@/blocks'
29
import type { BlockState } from '@/stores/workflows/workflow/types'
310

411
const logger = createLogger('SubblockMigrations')
@@ -88,3 +95,59 @@ export function migrateSubblockIds(blocks: Record<string, BlockState>): {
8895

8996
return { blocks: result, migrated: anyMigrated }
9097
}
98+
99+
/**
100+
* Backfills missing `canonicalModes` entries in block data.
101+
*
102+
* When a canonical pair is added to a block definition, existing blocks
103+
* won't have the entry in `data.canonicalModes`. Without it the editor
104+
* toggle may not render correctly. This resolves the correct mode based
105+
* on which subblock value is populated and adds the missing entry.
106+
*/
107+
export function backfillCanonicalModes(blocks: Record<string, BlockState>): {
108+
blocks: Record<string, BlockState>
109+
migrated: boolean
110+
} {
111+
let anyMigrated = false
112+
const result: Record<string, BlockState> = {}
113+
114+
for (const [blockId, block] of Object.entries(blocks)) {
115+
const blockConfig = getBlock(block.type)
116+
if (!blockConfig?.subBlocks || !block.subBlocks) {
117+
result[blockId] = block
118+
continue
119+
}
120+
121+
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
122+
const pairs = Object.values(canonicalIndex.groupsById).filter(isCanonicalPair)
123+
if (pairs.length === 0) {
124+
result[blockId] = block
125+
continue
126+
}
127+
128+
const existing = (block.data?.canonicalModes ?? {}) as Record<string, 'basic' | 'advanced'>
129+
let patched: Record<string, 'basic' | 'advanced'> | null = null
130+
131+
const values = buildSubBlockValues(block.subBlocks)
132+
133+
for (const group of pairs) {
134+
if (existing[group.canonicalId] != null) continue
135+
136+
const resolved = resolveCanonicalMode(group, values)
137+
if (!patched) patched = { ...existing }
138+
patched[group.canonicalId] = resolved
139+
}
140+
141+
if (patched) {
142+
anyMigrated = true
143+
result[blockId] = {
144+
...block,
145+
data: { ...(block.data ?? {}), canonicalModes: patched },
146+
}
147+
} else {
148+
result[blockId] = block
149+
}
150+
}
151+
152+
return { blocks: result, migrated: anyMigrated }
153+
}

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ 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'
17+
import {
18+
backfillCanonicalModes,
19+
migrateSubblockIds,
20+
} from '@/lib/workflows/migrations/subblock-migrations'
1821
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/sanitization/validation'
1922
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
2023
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
@@ -176,6 +179,11 @@ const applyBlockMigrations = createMigrationPipeline([
176179
const { blocks, migrated } = migrateSubblockIds(ctx.blocks)
177180
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
178181
},
182+
183+
(ctx) => {
184+
const { blocks, migrated } = backfillCanonicalModes(ctx.blocks)
185+
return { ...ctx, blocks, migrated: ctx.migrated || migrated }
186+
},
179187
])
180188

181189
/**
@@ -410,10 +418,14 @@ export async function loadWorkflowFromNormalizedTables(
410418
Promise.resolve().then(async () => {
411419
try {
412420
for (const [blockId, block] of Object.entries(finalBlocks)) {
413-
if (block.subBlocks !== blocksMap[blockId]?.subBlocks) {
421+
if (block !== blocksMap[blockId]) {
414422
await db
415423
.update(workflowBlocks)
416-
.set({ subBlocks: block.subBlocks, updatedAt: new Date() })
424+
.set({
425+
subBlocks: block.subBlocks,
426+
data: block.data,
427+
updatedAt: new Date(),
428+
})
417429
.where(
418430
and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId))
419431
)

0 commit comments

Comments
 (0)