fix(copilot-subflows): copilot-added subflows id mismatch (#1977)

This commit is contained in:
Vikhyath Mondreti
2025-11-13 17:56:26 -08:00
committed by GitHub
parent 5457d4bc7b
commit b3caef1f31
5 changed files with 188 additions and 23 deletions

View File

@@ -10,6 +10,8 @@ import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persi
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { getWorkflowAccessContext } from '@/lib/workflows/utils'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowStateAPI')
@@ -175,11 +177,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
{} as typeof state.blocks
)
const typedBlocks = filteredBlocks as Record<string, BlockState>
const canonicalLoops = generateLoopBlocks(typedBlocks)
const canonicalParallels = generateParallelBlocks(typedBlocks)
const workflowState = {
blocks: filteredBlocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
loops: canonicalLoops,
parallels: canonicalParallels,
lastSaved: state.lastSaved || Date.now(),
isDeployed: state.isDeployed || false,
deployedAt: state.deployedAt,

View File

@@ -124,6 +124,58 @@ const mockBlocksFromDb = [
parentId: 'loop-1',
extent: 'parent',
},
{
id: 'loop-1',
workflowId: mockWorkflowId,
type: 'loop',
name: 'Loop Container',
positionX: 50,
positionY: 50,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 250,
subBlocks: {},
outputs: {},
data: { width: 500, height: 300, loopType: 'for', count: 5 },
parentId: null,
extent: null,
},
{
id: 'parallel-1',
workflowId: mockWorkflowId,
type: 'parallel',
name: 'Parallel Container',
positionX: 600,
positionY: 50,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode: false,
height: 250,
subBlocks: {},
outputs: {},
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
parentId: null,
extent: null,
},
{
id: 'block-3',
workflowId: mockWorkflowId,
type: 'api',
name: 'Parallel Child',
positionX: 650,
positionY: 150,
enabled: true,
horizontalHandles: true,
height: 200,
subBlocks: {},
outputs: {},
data: { parentId: 'parallel-1', extent: 'parent' },
parentId: 'parallel-1',
extent: 'parent',
},
]
const mockEdgesFromDb = [
@@ -187,6 +239,42 @@ const mockWorkflowState: WorkflowState = {
height: 200,
data: { parentId: 'loop-1', extent: 'parent' },
},
'loop-1': {
id: 'loop-1',
type: 'loop',
name: 'Loop Container',
position: { x: 200, y: 50 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 250,
data: { width: 500, height: 300, count: 5, loopType: 'for' },
},
'parallel-1': {
id: 'parallel-1',
type: 'parallel',
name: 'Parallel Container',
position: { x: 600, y: 50 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 250,
data: { width: 500, height: 300, parallelType: 'count', count: 3 },
},
'block-3': {
id: 'block-3',
type: 'api',
name: 'Parallel Child',
position: { x: 650, y: 150 },
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
height: 180,
data: { parentId: 'parallel-1', extent: 'parent' },
},
},
edges: [
{
@@ -567,20 +655,36 @@ describe('Database Helpers', () => {
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, mockWorkflowState)
expect(capturedBlockInserts).toHaveLength(2)
expect(capturedBlockInserts[0]).toMatchObject({
id: 'block-1',
workflowId: mockWorkflowId,
type: 'starter',
name: 'Start Block',
positionX: '100',
positionY: '100',
enabled: true,
horizontalHandles: true,
height: '150',
parentId: null,
extent: null,
})
expect(capturedBlockInserts).toHaveLength(5)
expect(capturedBlockInserts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'block-1',
workflowId: mockWorkflowId,
type: 'starter',
name: 'Start Block',
positionX: '100',
positionY: '100',
enabled: true,
horizontalHandles: true,
height: '150',
parentId: null,
extent: null,
}),
expect.objectContaining({
id: 'loop-1',
workflowId: mockWorkflowId,
type: 'loop',
parentId: null,
}),
expect.objectContaining({
id: 'parallel-1',
workflowId: mockWorkflowId,
type: 'parallel',
parentId: null,
}),
])
)
expect(capturedEdgeInserts).toHaveLength(1)
expect(capturedEdgeInserts[0]).toMatchObject({
@@ -599,6 +703,48 @@ describe('Database Helpers', () => {
type: 'loop',
})
})
it('should regenerate missing loop and parallel definitions from block data', async () => {
let capturedSubflowInserts: any[] = []
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
}),
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
insert: vi.fn().mockReturnValue({
values: vi.fn().mockImplementation((data) => {
if (data.length > 0 && (data[0].type === 'loop' || data[0].type === 'parallel')) {
capturedSubflowInserts = data
}
return Promise.resolve([])
}),
}),
}
return await callback(tx)
})
mockDb.transaction = mockTransaction
const staleWorkflowState = JSON.parse(JSON.stringify(mockWorkflowState)) as WorkflowState
staleWorkflowState.loops = {}
staleWorkflowState.parallels = {}
await dbHelpers.saveWorkflowToNormalizedTables(mockWorkflowId, staleWorkflowState)
expect(capturedSubflowInserts).toHaveLength(2)
expect(capturedSubflowInserts).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'loop-1', type: 'loop' }),
expect.objectContaining({ id: 'parallel-1', type: 'parallel' }),
])
)
})
})
describe('workflowExistsInNormalizedTables', () => {

View File

@@ -16,6 +16,7 @@ import { createLogger } from '@/lib/logs/console/logger'
import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { SUBFLOW_TYPES } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
const logger = createLogger('WorkflowDBHelpers')
@@ -248,6 +249,10 @@ export async function saveWorkflowToNormalizedTables(
state: WorkflowState
): Promise<{ success: boolean; error?: string }> {
try {
const blockRecords = state.blocks as Record<string, BlockState>
const canonicalLoops = generateLoopBlocks(blockRecords)
const canonicalParallels = generateParallelBlocks(blockRecords)
// Start a transaction
await db.transaction(async (tx) => {
// Snapshot existing webhooks before deletion to preserve them through the cycle
@@ -310,7 +315,7 @@ export async function saveWorkflowToNormalizedTables(
const subflowInserts: any[] = []
// Add loops
Object.values(state.loops || {}).forEach((loop) => {
Object.values(canonicalLoops).forEach((loop) => {
subflowInserts.push({
id: loop.id,
workflowId: workflowId,
@@ -320,7 +325,7 @@ export async function saveWorkflowToNormalizedTables(
})
// Add parallels
Object.values(state.parallels || {}).forEach((parallel) => {
Object.values(canonicalParallels).forEach((parallel) => {
subflowInserts.push({
id: parallel.id,
workflowId: workflowId,

View File

@@ -1,6 +1,7 @@
import type { Edge } from 'reactflow'
import { sanitizeWorkflowForSharing } from '@/lib/workflows/credential-extractor'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { TRIGGER_PERSISTED_SUBBLOCK_IDS } from '@/triggers/consts'
/**
@@ -386,12 +387,15 @@ export function sanitizeForCopilot(state: WorkflowState): CopilotWorkflowState {
* Users need positions to restore the visual layout when importing
*/
export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
const canonicalLoops = generateLoopBlocks(state.blocks || {})
const canonicalParallels = generateParallelBlocks(state.blocks || {})
// Preserve edges, loops, parallels, metadata, and variables
const fullState = {
blocks: state.blocks,
edges: state.edges,
loops: state.loops || {},
parallels: state.parallels || {},
loops: canonicalLoops,
parallels: canonicalParallels,
metadata: state.metadata,
variables: state.variables,
}

View File

@@ -5,6 +5,7 @@ import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { getTool } from '@/tools/utils'
const logger = createLogger('Serializer')
@@ -41,12 +42,15 @@ export class Serializer {
serializeWorkflow(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
loops?: Record<string, Loop>,
parallels?: Record<string, Parallel>,
validateRequired = false
): SerializedWorkflow {
const safeLoops = loops || {}
const safeParallels = parallels || {}
const canonicalLoops = generateLoopBlocks(blocks)
const canonicalParallels = generateParallelBlocks(blocks)
const safeLoops = Object.keys(canonicalLoops).length > 0 ? canonicalLoops : loops || {}
const safeParallels =
Object.keys(canonicalParallels).length > 0 ? canonicalParallels : parallels || {}
const accessibleBlocksMap = this.computeAccessibleBlockIds(
blocks,
edges,