chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { upsertCustomTools } from '@/lib/workflows/custom-tools/operations'
|
||||
|
||||
const logger = createLogger('CustomToolsPersistence')
|
||||
|
||||
interface CustomTool {
|
||||
id?: string
|
||||
type: 'custom-tool'
|
||||
title: string
|
||||
toolId?: string
|
||||
schema: {
|
||||
function: {
|
||||
name?: string
|
||||
description: string
|
||||
parameters: Record<string, any>
|
||||
}
|
||||
}
|
||||
code: string
|
||||
usageControl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored tool format that may contain either reference or inline definition
|
||||
*/
|
||||
interface StoredCustomTool {
|
||||
type: string
|
||||
title?: string
|
||||
toolId?: string
|
||||
customToolId?: string
|
||||
schema?: any
|
||||
code?: string
|
||||
usageControl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a stored tool is a reference-only custom tool (no inline definition)
|
||||
*/
|
||||
function isCustomToolReference(tool: StoredCustomTool): boolean {
|
||||
return tool.type === 'custom-tool' && !!tool.customToolId && !tool.code
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all custom tools from agent blocks in the workflow state
|
||||
*
|
||||
* @remarks
|
||||
* Only extracts tools with inline definitions (legacy format).
|
||||
* Reference-only tools (new format with customToolId) are skipped since they're already in the database.
|
||||
*/
|
||||
export function extractCustomToolsFromWorkflowState(workflowState: any): CustomTool[] {
|
||||
const customToolsMap = new Map<string, CustomTool>()
|
||||
|
||||
if (!workflowState?.blocks) {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const [blockId, block] of Object.entries(workflowState.blocks)) {
|
||||
try {
|
||||
const blockData = block as any
|
||||
|
||||
if (!blockData || blockData.type !== 'agent') {
|
||||
continue
|
||||
}
|
||||
|
||||
const subBlocks = blockData.subBlocks || {}
|
||||
const toolsSubBlock = subBlocks.tools
|
||||
|
||||
if (!toolsSubBlock?.value) {
|
||||
continue
|
||||
}
|
||||
|
||||
let tools = toolsSubBlock.value
|
||||
|
||||
if (typeof tools === 'string') {
|
||||
try {
|
||||
tools = JSON.parse(tools)
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to parse tools in block ${blockId}`, { error })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(tools)) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
if (!tool || typeof tool !== 'object' || tool.type !== 'custom-tool') {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip reference-only tools - they're already in the database
|
||||
if (isCustomToolReference(tool)) {
|
||||
logger.debug(`Skipping reference-only custom tool: ${tool.title || tool.customToolId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Only persist tools with inline definitions (legacy format)
|
||||
if (tool.title && tool.schema?.function && tool.code) {
|
||||
const toolKey = tool.toolId || tool.title
|
||||
|
||||
if (!customToolsMap.has(toolKey)) {
|
||||
customToolsMap.set(toolKey, tool as CustomTool)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error extracting custom tools from block ${blockId}`, { error })
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(customToolsMap.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist custom tools to the database using the upsert function
|
||||
* Creates new tools or updates existing ones
|
||||
*/
|
||||
export async function persistCustomToolsToDatabase(
|
||||
customToolsList: CustomTool[],
|
||||
workspaceId: string | null,
|
||||
userId: string
|
||||
): Promise<{ saved: number; errors: string[] }> {
|
||||
if (!customToolsList || customToolsList.length === 0) {
|
||||
return { saved: 0, errors: [] }
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
logger.debug('Skipping custom tools persistence - no workspaceId provided (user-scoped tools)')
|
||||
return { saved: 0, errors: [] }
|
||||
}
|
||||
|
||||
const errors: string[] = []
|
||||
let saved = 0
|
||||
|
||||
const validTools = customToolsList.filter((tool) => {
|
||||
if (!tool.schema?.function?.name) {
|
||||
logger.warn(`Skipping custom tool without function name: ${tool.title}`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (validTools.length === 0) {
|
||||
return { saved: 0, errors: [] }
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertCustomTools({
|
||||
tools: validTools.map((tool) => ({
|
||||
id: tool.toolId,
|
||||
title: tool.title,
|
||||
schema: tool.schema,
|
||||
code: tool.code,
|
||||
})),
|
||||
workspaceId,
|
||||
userId,
|
||||
})
|
||||
|
||||
saved = validTools.length
|
||||
logger.info(`Persisted ${saved} custom tool(s)`, { workspaceId })
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to persist custom tools: ${toError(error).message}`
|
||||
logger.error(errorMsg, { error })
|
||||
errors.push(errorMsg)
|
||||
}
|
||||
|
||||
return { saved, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and persist custom tools from workflow state
|
||||
*/
|
||||
export async function extractAndPersistCustomTools(
|
||||
workflowState: any,
|
||||
workspaceId: string | null,
|
||||
userId: string
|
||||
): Promise<{ saved: number; errors: string[] }> {
|
||||
const customToolsList = extractCustomToolsFromWorkflowState(workflowState)
|
||||
|
||||
if (customToolsList.length === 0) {
|
||||
logger.debug('No custom tools found in workflow state')
|
||||
return { saved: 0, errors: [] }
|
||||
}
|
||||
|
||||
logger.info(`Found ${customToolsList.length} custom tool(s) to persist`, {
|
||||
tools: customToolsList.map((t) => t.title),
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
return await persistCustomToolsToDatabase(customToolsList, workspaceId, userId)
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { workflowAuthzMockFns, workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockAuthorizeWorkflowByWorkspacePermission =
|
||||
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
|
||||
|
||||
const { mockDb } = vi.hoisted(() => ({
|
||||
mockDb: {
|
||||
transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
...drizzleOrmMock,
|
||||
min: vi.fn((field) => ({ type: 'min', field })),
|
||||
}))
|
||||
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: mockDb,
|
||||
}))
|
||||
|
||||
import { duplicateWorkflow } from './duplicate'
|
||||
|
||||
function createMockTx(
|
||||
selectResults: unknown[],
|
||||
onWorkflowInsert?: (values: Record<string, unknown>) => void,
|
||||
onInsert?: (values: unknown) => void
|
||||
) {
|
||||
let selectCallCount = 0
|
||||
|
||||
const select = vi.fn().mockImplementation(() => ({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockImplementation(() => {
|
||||
const result = selectResults[selectCallCount++] ?? []
|
||||
if (selectCallCount === 1) {
|
||||
return {
|
||||
limit: vi.fn().mockResolvedValue(result),
|
||||
}
|
||||
}
|
||||
return Promise.resolve(result)
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const insert = vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
onWorkflowInsert?.(values)
|
||||
onInsert?.(values)
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
})
|
||||
|
||||
const update = vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
})
|
||||
|
||||
return {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
describe('duplicateWorkflow ordering', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: vi.fn().mockReturnValue('new-workflow-id'),
|
||||
})
|
||||
|
||||
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
|
||||
allowed: true,
|
||||
status: 200,
|
||||
workflow: { id: 'source-workflow-id', workspaceId: 'workspace-123' },
|
||||
workspacePermission: 'write',
|
||||
})
|
||||
workflowsUtilsMockFns.mockDeduplicateWorkflowName.mockImplementation(
|
||||
async (name: string) => name
|
||||
)
|
||||
})
|
||||
|
||||
it('uses mixed-sibling top insertion sort order', async () => {
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[{ minOrder: 5 }],
|
||||
[{ minOrder: 2 }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-1',
|
||||
})
|
||||
|
||||
expect(result.sortOrder).toBe(1)
|
||||
expect(insertedWorkflowValues?.sortOrder).toBe(1)
|
||||
})
|
||||
|
||||
it('defaults to sortOrder 0 when target has no siblings', async () => {
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
const result = await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-2',
|
||||
})
|
||||
|
||||
expect(result.sortOrder).toBe(0)
|
||||
expect(insertedWorkflowValues?.sortOrder).toBe(0)
|
||||
})
|
||||
|
||||
it('strips copied webhook runtime subblocks and remaps variable assignments', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
|
||||
variables: {
|
||||
'old-var-id': {
|
||||
id: 'old-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'generic_webhook',
|
||||
name: 'Webhook',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
triggerPath: { id: 'triggerPath', type: 'short-input', value: 'old-webhook-path' },
|
||||
webhookId: { id: 'webhookId', type: 'short-input', value: 'old-webhook-id' },
|
||||
webhookUrlDisplay: {
|
||||
id: 'webhookUrlDisplay',
|
||||
type: 'short-input',
|
||||
value: 'https://example.test/api/webhooks/trigger/old-webhook-path',
|
||||
},
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: JSON.stringify([
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'old-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: true,
|
||||
locked: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-3',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.triggerPath).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookId).toBeUndefined()
|
||||
expect(copiedSubBlocks.webhookUrlDisplay).toBeUndefined()
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).not.toBe('old-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
expect(insertedBlocks?.[0].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('remaps variable assignments when duplicating an already-duplicated source (array value)', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
let insertedWorkflowValues: Record<string, unknown> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'first-copy-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'first copy',
|
||||
|
||||
variables: {
|
||||
'first-copy-var-id': {
|
||||
id: 'first-copy-var-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'first-copy-block-id',
|
||||
workflowId: 'first-copy-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'first-copy-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
(values) => {
|
||||
if (!insertedWorkflowValues && !Array.isArray(values)) {
|
||||
insertedWorkflowValues = values
|
||||
}
|
||||
},
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await duplicateWorkflow({
|
||||
sourceWorkflowId: 'first-copy-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated Again',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-second-copy',
|
||||
})
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(Array.isArray(copiedSubBlocks.variables.value)).toBe(true)
|
||||
expect(copiedSubBlocks.variables.value).toHaveLength(1)
|
||||
|
||||
const newVarIds = Object.keys(
|
||||
(insertedWorkflowValues?.variables as Record<string, unknown>) || {}
|
||||
)
|
||||
expect(newVarIds).toHaveLength(1)
|
||||
const remappedVarId = copiedSubBlocks.variables.value[0].variableId
|
||||
expect(remappedVarId).not.toBe('first-copy-var-id')
|
||||
expect(remappedVarId).toBe(newVarIds[0])
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
|
||||
it('preserves remap when an edge references an unknown source block (drops the edge with a warning)', async () => {
|
||||
let insertedEdges: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'block-a',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent A',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'block-b',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent B',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: 'edge-valid',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'block-a',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'edge-orphan',
|
||||
workflowId: 'source-workflow-id',
|
||||
sourceBlockId: 'unknown-source-block',
|
||||
targetBlockId: 'block-b',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.sourceBlockId !== undefined
|
||||
) {
|
||||
insertedEdges = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-edge',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedEdges).toHaveLength(1)
|
||||
const onlyEdge = insertedEdges?.[0]
|
||||
expect(onlyEdge?.sourceBlockId).not.toBe('unknown-source-block')
|
||||
expect(onlyEdge?.sourceBlockId).toEqual(expect.any(String))
|
||||
expect(onlyEdge?.targetBlockId).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves remap when a subflow references an unknown node (drops the node with a warning)', async () => {
|
||||
let insertedSubflows: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
variables: {},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
name: 'Loop',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'known-node',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'loop-block',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'loop',
|
||||
config: {
|
||||
id: 'loop-block',
|
||||
nodes: ['known-node', 'unknown-node'],
|
||||
iterations: 1,
|
||||
loopType: 'for',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (
|
||||
Array.isArray(values) &&
|
||||
values.length > 0 &&
|
||||
(values[0] as Record<string, unknown>)?.config !== undefined
|
||||
) {
|
||||
insertedSubflows = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-orphan-subflow',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedSubflows).toHaveLength(1)
|
||||
const remappedConfig = insertedSubflows?.[0].config as { nodes: string[] }
|
||||
expect(Array.isArray(remappedConfig.nodes)).toBe(true)
|
||||
expect(remappedConfig.nodes).toHaveLength(1)
|
||||
expect(remappedConfig.nodes[0]).not.toBe('unknown-node')
|
||||
expect(remappedConfig.nodes[0]).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('preserves stale variable references instead of failing the duplicate', async () => {
|
||||
let insertedBlocks: Array<Record<string, unknown>> | null = null
|
||||
const tx = createMockTx(
|
||||
[
|
||||
[
|
||||
{
|
||||
id: 'source-workflow-id',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
description: 'source',
|
||||
|
||||
variables: {
|
||||
'live-var-id': {
|
||||
id: 'live-var-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
name: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Ada',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: 'source-block-id',
|
||||
workflowId: 'source-workflow-id',
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
parentId: null,
|
||||
extent: null,
|
||||
data: {},
|
||||
subBlocks: {
|
||||
variables: {
|
||||
id: 'variables',
|
||||
type: 'variables-input',
|
||||
value: [
|
||||
{
|
||||
id: 'assignment-1',
|
||||
variableId: 'deleted-var-id',
|
||||
variableName: 'customerName',
|
||||
type: 'string',
|
||||
value: 'Grace',
|
||||
isExisting: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
enabled: true,
|
||||
horizontalHandles: true,
|
||||
isWide: false,
|
||||
height: 0,
|
||||
advancedMode: false,
|
||||
triggerMode: false,
|
||||
locked: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
undefined,
|
||||
(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
insertedBlocks = values as Array<Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockDb.transaction.mockImplementation(async (callback: (txArg: unknown) => Promise<unknown>) =>
|
||||
callback(tx)
|
||||
)
|
||||
|
||||
await expect(
|
||||
duplicateWorkflow({
|
||||
sourceWorkflowId: 'source-workflow-id',
|
||||
userId: 'user-123',
|
||||
name: 'Duplicated',
|
||||
workspaceId: 'workspace-123',
|
||||
folderId: null,
|
||||
requestId: 'req-stale',
|
||||
})
|
||||
).resolves.toBeDefined()
|
||||
|
||||
expect(insertedBlocks).toHaveLength(1)
|
||||
const copiedSubBlocks = insertedBlocks?.[0].subBlocks as Record<string, any>
|
||||
expect(copiedSubBlocks.variables.value[0].variableId).toBe('deleted-var-id')
|
||||
expect(copiedSubBlocks.variables.value[0].variableName).toBe('customerName')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,521 @@
|
||||
import { db } from '@sim/db'
|
||||
import {
|
||||
workflow,
|
||||
workflowBlocks,
|
||||
workflowEdges,
|
||||
workflowFolder,
|
||||
workflowSubflows,
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
authorizeWorkflowByWorkspacePermission,
|
||||
FolderLockedError,
|
||||
} from '@sim/platform-authz/workflow'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
remapConditionIdsInSubBlocks,
|
||||
remapVariableIdsInSubBlocks,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
sanitizeSubBlocksForDuplicate,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
|
||||
import type { Variable } from '@/stores/variables/types'
|
||||
import type { LoopConfig, ParallelConfig } from '@/stores/workflows/workflow/types'
|
||||
|
||||
const logger = createLogger('WorkflowDuplicateHelper')
|
||||
|
||||
interface DuplicateWorkflowOptions {
|
||||
sourceWorkflowId: string
|
||||
userId: string
|
||||
name: string
|
||||
description?: string
|
||||
workspaceId?: string
|
||||
folderId?: string | null
|
||||
requestId?: string
|
||||
newWorkflowId?: string
|
||||
/**
|
||||
* Run inside the caller's transaction. Callers that pass `tx` must have
|
||||
* already authorized the user on the source workflow's workspace: the
|
||||
* authorization helpers query through the global pool, so running them here
|
||||
* would require a second pooled connection while the caller's transaction
|
||||
* holds the first.
|
||||
*/
|
||||
tx?: DbOrTx
|
||||
workflowIdMap?: Map<string, string>
|
||||
}
|
||||
|
||||
interface DuplicateWorkflowResult {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
workspaceId: string
|
||||
folderId: string | null
|
||||
sortOrder: number
|
||||
locked: boolean
|
||||
blocksCount: number
|
||||
edgesCount: number
|
||||
subflowsCount: number
|
||||
}
|
||||
|
||||
async function assertTargetFolderMutable(
|
||||
tx: DbOrTx,
|
||||
folderId: string | null,
|
||||
targetWorkspaceId: string
|
||||
): Promise<void> {
|
||||
let currentFolderId = folderId
|
||||
const visited = new Set<string>()
|
||||
|
||||
while (currentFolderId && !visited.has(currentFolderId)) {
|
||||
visited.add(currentFolderId)
|
||||
const [folder] = await tx
|
||||
.select({
|
||||
id: workflowFolder.id,
|
||||
parentId: workflowFolder.parentId,
|
||||
workspaceId: workflowFolder.workspaceId,
|
||||
locked: workflowFolder.locked,
|
||||
archivedAt: workflowFolder.archivedAt,
|
||||
})
|
||||
.from(workflowFolder)
|
||||
.where(eq(workflowFolder.id, currentFolderId))
|
||||
.limit(1)
|
||||
|
||||
if (!folder || folder.workspaceId !== targetWorkspaceId || folder.archivedAt) {
|
||||
throw new Error('Target folder not found')
|
||||
}
|
||||
if (folder.locked) {
|
||||
throw new FolderLockedError()
|
||||
}
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
*/
|
||||
export async function duplicateWorkflow(
|
||||
options: DuplicateWorkflowOptions
|
||||
): Promise<DuplicateWorkflowResult> {
|
||||
const {
|
||||
sourceWorkflowId,
|
||||
userId,
|
||||
name,
|
||||
description,
|
||||
workspaceId,
|
||||
folderId,
|
||||
requestId = 'unknown',
|
||||
newWorkflowId: clientNewWorkflowId,
|
||||
tx: providedTx,
|
||||
workflowIdMap,
|
||||
} = options
|
||||
|
||||
const newWorkflowId = clientNewWorkflowId || workflowIdMap?.get(sourceWorkflowId) || generateId()
|
||||
const now = new Date()
|
||||
|
||||
// Authorization runs before the transaction opens so its global-pool
|
||||
// queries never execute while a pooled connection is held. Callers that
|
||||
// pass `tx` authorize the workspace themselves (see DuplicateWorkflowOptions).
|
||||
if (!providedTx) {
|
||||
const sourceAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId: sourceWorkflowId,
|
||||
userId,
|
||||
action: 'read',
|
||||
})
|
||||
if (!sourceAuthorization.allowed || !sourceAuthorization.workflow) {
|
||||
throw new Error('Source workflow not found or access denied')
|
||||
}
|
||||
|
||||
const sourceWorkspaceId = sourceAuthorization.workflow.workspaceId
|
||||
if (!sourceWorkspaceId) {
|
||||
throw new Error(
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be duplicated.'
|
||||
)
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || sourceWorkspaceId
|
||||
if (targetWorkspaceId !== sourceWorkspaceId) {
|
||||
throw new Error('Cross-workspace workflow duplication is not supported')
|
||||
}
|
||||
|
||||
// The target workspace equals the source workspace, so the permission
|
||||
// resolved by the authorization above is the target permission.
|
||||
if (
|
||||
sourceAuthorization.workspacePermission !== 'admin' &&
|
||||
sourceAuthorization.workspacePermission !== 'write'
|
||||
) {
|
||||
throw new Error('Write or admin access required for target workspace')
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateWithinTransaction = async (tx: DbOrTx) => {
|
||||
// First verify the source workflow exists
|
||||
const sourceWorkflowRow = await tx
|
||||
.select()
|
||||
.from(workflow)
|
||||
.where(eq(workflow.id, sourceWorkflowId))
|
||||
.limit(1)
|
||||
|
||||
if (sourceWorkflowRow.length === 0) {
|
||||
throw new Error('Source workflow not found')
|
||||
}
|
||||
|
||||
const source = sourceWorkflowRow[0]
|
||||
if (!source.workspaceId) {
|
||||
throw new Error(
|
||||
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be duplicated.'
|
||||
)
|
||||
}
|
||||
|
||||
const targetWorkspaceId = workspaceId || source.workspaceId
|
||||
if (targetWorkspaceId !== source.workspaceId) {
|
||||
throw new Error('Cross-workspace workflow duplication is not supported')
|
||||
}
|
||||
|
||||
const targetFolderId = folderId !== undefined ? folderId : source.folderId
|
||||
await assertTargetFolderMutable(tx, targetFolderId, targetWorkspaceId)
|
||||
|
||||
const workflowParentCondition = targetFolderId
|
||||
? eq(workflow.folderId, targetFolderId)
|
||||
: isNull(workflow.folderId)
|
||||
const folderParentCondition = targetFolderId
|
||||
? eq(workflowFolder.parentId, targetFolderId)
|
||||
: isNull(workflowFolder.parentId)
|
||||
|
||||
const [[workflowMinResult], [folderMinResult]] = await Promise.all([
|
||||
tx
|
||||
.select({ minOrder: min(workflow.sortOrder) })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.workspaceId, targetWorkspaceId), workflowParentCondition)),
|
||||
tx
|
||||
.select({ minOrder: min(workflowFolder.sortOrder) })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.workspaceId, targetWorkspaceId), folderParentCondition)),
|
||||
])
|
||||
const minSortOrder = [workflowMinResult?.minOrder, folderMinResult?.minOrder].reduce<
|
||||
number | null
|
||||
>((currentMin, candidate) => {
|
||||
if (candidate == null) return currentMin
|
||||
if (currentMin == null) return candidate
|
||||
return Math.min(currentMin, candidate)
|
||||
}, null)
|
||||
const sortOrder = minSortOrder != null ? minSortOrder - 1 : 0
|
||||
|
||||
// Mapping from old variable IDs to new variable IDs (populated during variable duplication)
|
||||
const varIdMapping = new Map<string, string>()
|
||||
|
||||
const deduplicatedName = await deduplicateWorkflowName(
|
||||
name,
|
||||
targetWorkspaceId,
|
||||
targetFolderId,
|
||||
tx
|
||||
)
|
||||
|
||||
await tx.insert(workflow).values({
|
||||
id: newWorkflowId,
|
||||
userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder,
|
||||
name: deduplicatedName,
|
||||
description: description || source.description,
|
||||
lastSynced: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
isDeployed: false,
|
||||
runCount: 0,
|
||||
locked: false,
|
||||
// Duplicate variables with new IDs and new workflowId
|
||||
variables: (() => {
|
||||
const sourceVars = (source.variables as Record<string, Variable>) || {}
|
||||
const remapped: Record<string, Variable> = {}
|
||||
for (const [oldVarId, variable] of Object.entries(sourceVars) as [string, Variable][]) {
|
||||
const newVarId = generateId()
|
||||
varIdMapping.set(oldVarId, newVarId)
|
||||
remapped[newVarId] = {
|
||||
...variable,
|
||||
id: newVarId,
|
||||
workflowId: newWorkflowId,
|
||||
}
|
||||
}
|
||||
return remapped
|
||||
})(),
|
||||
})
|
||||
|
||||
// Copy all blocks from source workflow with new IDs
|
||||
const sourceBlocks = await tx
|
||||
.select()
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, sourceWorkflowId))
|
||||
|
||||
// Create a mapping from old block IDs to new block IDs
|
||||
const blockIdMapping = new Map<string, string>()
|
||||
|
||||
if (sourceBlocks.length > 0) {
|
||||
// First pass: Create all block ID mappings
|
||||
sourceBlocks.forEach((block) => {
|
||||
const newBlockId = generateId()
|
||||
blockIdMapping.set(block.id, newBlockId)
|
||||
})
|
||||
|
||||
// Second pass: Create blocks with updated parent relationships
|
||||
const newBlocks = sourceBlocks.map((block) => {
|
||||
const newBlockId = blockIdMapping.get(block.id)!
|
||||
|
||||
// Update parent ID to point to the new parent block ID if it exists
|
||||
const blockData =
|
||||
block.data && typeof block.data === 'object' && !Array.isArray(block.data)
|
||||
? (block.data as any)
|
||||
: {}
|
||||
let newParentId = blockData.parentId
|
||||
if (blockData.parentId && blockIdMapping.has(blockData.parentId)) {
|
||||
newParentId = blockIdMapping.get(blockData.parentId)!
|
||||
}
|
||||
|
||||
// Update data.parentId and extent if they exist in the data object
|
||||
let updatedData = block.data
|
||||
let newExtent = blockData.extent
|
||||
if (block.data && typeof block.data === 'object' && !Array.isArray(block.data)) {
|
||||
const dataObj = block.data as any
|
||||
if (dataObj.parentId && typeof dataObj.parentId === 'string') {
|
||||
updatedData = { ...dataObj }
|
||||
if (blockIdMapping.has(dataObj.parentId)) {
|
||||
;(updatedData as any).parentId = blockIdMapping.get(dataObj.parentId)!
|
||||
// Ensure extent is set to 'parent' for child blocks
|
||||
;(updatedData as any).extent = 'parent'
|
||||
newExtent = 'parent'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update variable references in subBlocks (e.g. variables-input assignments)
|
||||
let updatedSubBlocks = block.subBlocks
|
||||
if (
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = sanitizeSubBlocksForDuplicate(updatedSubBlocks as SubBlockRecord)
|
||||
}
|
||||
if (
|
||||
varIdMapping.size > 0 &&
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = remapVariableIdsInSubBlocks(
|
||||
updatedSubBlocks as SubBlockRecord,
|
||||
varIdMapping
|
||||
)
|
||||
}
|
||||
if (
|
||||
updatedSubBlocks &&
|
||||
typeof updatedSubBlocks === 'object' &&
|
||||
!Array.isArray(updatedSubBlocks)
|
||||
) {
|
||||
updatedSubBlocks = remapWorkflowReferencesInSubBlocks(
|
||||
updatedSubBlocks as SubBlockRecord,
|
||||
workflowIdMap
|
||||
)
|
||||
}
|
||||
|
||||
// Remap condition/router IDs to use the new block ID
|
||||
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
|
||||
updatedSubBlocks = remapConditionIdsInSubBlocks(
|
||||
updatedSubBlocks as Record<string, any>,
|
||||
block.id,
|
||||
newBlockId
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
workflowId: newWorkflowId,
|
||||
parentId: newParentId,
|
||||
extent: newExtent,
|
||||
data: updatedData,
|
||||
subBlocks: updatedSubBlocks,
|
||||
locked: false, // Duplicated blocks should always be unlocked
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowBlocks).values(newBlocks)
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${sourceBlocks.length} blocks with updated parent relationships`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all edges from source workflow with updated block references
|
||||
const sourceEdges = await tx
|
||||
.select()
|
||||
.from(workflowEdges)
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
/**
|
||||
* Edge remap is best-effort: when an edge points at a source/target block
|
||||
* that isn't in `blockIdMapping`, we drop the edge with a `logger.warn`
|
||||
* instead of throwing. This matches the pre-PR leniency (which silently
|
||||
* kept stale references) and avoids rolling back an entire folder-tree
|
||||
* duplicate transaction over a single orphaned reference. Inserting an
|
||||
* edge with a stale block id would create a dangling DB row, so we skip
|
||||
* the edge entirely rather than carry forward the unmapped id.
|
||||
*/
|
||||
const newEdges = sourceEdges.flatMap((edge) => {
|
||||
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId)
|
||||
const newTargetBlockId = blockIdMapping.get(edge.targetBlockId)
|
||||
if (!newSourceBlockId || !newTargetBlockId) {
|
||||
logger.warn('Skipping edge with unmapped block reference during duplication', {
|
||||
requestId,
|
||||
edgeId: edge.id,
|
||||
sourceBlockId: edge.sourceBlockId,
|
||||
targetBlockId: edge.targetBlockId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
|
||||
: edge.sourceHandle
|
||||
|
||||
return [
|
||||
{
|
||||
...edge,
|
||||
id: generateId(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: newSourceBlockId,
|
||||
targetBlockId: newTargetBlockId,
|
||||
sourceHandle: newSourceHandle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
if (newEdges.length > 0) {
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
}
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${newEdges.length}/${sourceEdges.length} edges with updated block references`
|
||||
)
|
||||
}
|
||||
|
||||
// Copy all subflows from source workflow with new IDs and updated block references
|
||||
const sourceSubflows = await tx
|
||||
.select()
|
||||
.from(workflowSubflows)
|
||||
.where(eq(workflowSubflows.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceSubflows.length > 0) {
|
||||
const newSubflows = sourceSubflows
|
||||
.map((subflow) => {
|
||||
// The subflow ID should match the corresponding block ID
|
||||
const newSubflowId = blockIdMapping.get(subflow.id)
|
||||
|
||||
if (!newSubflowId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Subflow ${subflow.id} (${subflow.type}) has no corresponding block, skipping`
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Mapping subflow ${subflow.id} → ${newSubflowId}`, {
|
||||
subflowType: subflow.type,
|
||||
})
|
||||
|
||||
// Update block references in subflow config
|
||||
let updatedConfig: LoopConfig | ParallelConfig = subflow.config as
|
||||
| LoopConfig
|
||||
| ParallelConfig
|
||||
if (subflow.config && typeof subflow.config === 'object') {
|
||||
updatedConfig = structuredClone(subflow.config) as LoopConfig | ParallelConfig
|
||||
|
||||
// Update the config ID to match the new subflow ID
|
||||
|
||||
;(updatedConfig as any).id = newSubflowId
|
||||
|
||||
/**
|
||||
* Subflow node remap is best-effort: when `config.nodes` lists a
|
||||
* block id that isn't in `blockIdMapping`, we drop the entry with
|
||||
* a `logger.warn` instead of throwing. This matches the pre-PR
|
||||
* leniency (which silently carried the stale id forward) and
|
||||
* avoids rolling back an entire folder-tree duplicate transaction
|
||||
* over a single orphaned reference. Downstream consumers and
|
||||
* cleanup tolerate missing membership entries the same way they
|
||||
* tolerate other persisted drift.
|
||||
*/
|
||||
if ('nodes' in updatedConfig && Array.isArray(updatedConfig.nodes)) {
|
||||
updatedConfig.nodes = updatedConfig.nodes.flatMap((nodeId: string) => {
|
||||
const newNodeId = blockIdMapping.get(nodeId)
|
||||
if (!newNodeId) {
|
||||
logger.warn('Skipping unmapped subflow node reference during duplication', {
|
||||
requestId,
|
||||
subflowId: subflow.id,
|
||||
nodeId,
|
||||
})
|
||||
return []
|
||||
}
|
||||
return [newNodeId]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...subflow,
|
||||
id: newSubflowId, // Use the same ID as the corresponding block
|
||||
workflowId: newWorkflowId,
|
||||
config: updatedConfig,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
.filter((subflow): subflow is NonNullable<typeof subflow> => subflow !== null)
|
||||
|
||||
if (newSubflows.length > 0) {
|
||||
await tx.insert(workflowSubflows).values(newSubflows)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Copied ${newSubflows.length}/${sourceSubflows.length} subflows with updated block references and matching IDs`
|
||||
)
|
||||
}
|
||||
|
||||
// Update the workflow timestamp
|
||||
await tx
|
||||
.update(workflow)
|
||||
.set({
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(workflow.id, newWorkflowId))
|
||||
|
||||
const finalWorkspaceId = workspaceId || source.workspaceId
|
||||
if (!finalWorkspaceId) {
|
||||
throw new Error('Workspace ID is required')
|
||||
}
|
||||
|
||||
return {
|
||||
id: newWorkflowId,
|
||||
name: deduplicatedName,
|
||||
description: description || source.description,
|
||||
workspaceId: finalWorkspaceId,
|
||||
folderId: targetFolderId,
|
||||
sortOrder,
|
||||
locked: false,
|
||||
blocksCount: sourceBlocks.length,
|
||||
edgesCount: sourceEdges.length,
|
||||
subflowsCount: sourceSubflows.length,
|
||||
}
|
||||
}
|
||||
|
||||
const result = providedTx
|
||||
? await duplicateWithinTransaction(providedTx)
|
||||
: await db.transaction(duplicateWithinTransaction)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
coerceObjectArray,
|
||||
remapWorkflowReferencesInSubBlocks,
|
||||
type SubBlockRecord,
|
||||
} from '@/lib/workflows/persistence/remap-internal-ids'
|
||||
|
||||
describe('remapWorkflowReferencesInSubBlocks', () => {
|
||||
const map = new Map([
|
||||
['wf-src', 'wf-dst'],
|
||||
['sub-src', 'sub-dst'],
|
||||
])
|
||||
|
||||
it('remaps a top-level workflow-selector value', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.target.value).toBe('wf-dst')
|
||||
})
|
||||
|
||||
it('remaps a nested workflow_input tool workflowId in a tool-input array', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [
|
||||
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
|
||||
{ type: 'custom-tool', customToolId: 'ct-1' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
|
||||
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
||||
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
|
||||
})
|
||||
|
||||
it('handles a JSON-stringified tool-input value', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-src' } }]),
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.tools.value).toBe(
|
||||
JSON.stringify([{ type: 'workflow_input', params: { workflowId: 'sub-dst' } }])
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves unknown workflow ids and non-workflow tools untouched', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } }],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.sel.value).toBe('wf-unknown')
|
||||
expect(result.tools).toBe(subBlocks.tools)
|
||||
})
|
||||
|
||||
it('returns the input unchanged when the id map is empty', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
target: { id: 'target', type: 'workflow-selector', value: 'wf-src' },
|
||||
}
|
||||
expect(remapWorkflowReferencesInSubBlocks(subBlocks, new Map())).toBe(subBlocks)
|
||||
})
|
||||
|
||||
it('clears an unmapped workflow-selector when clearUnmapped is set (cross-workspace)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
sel: { id: 'sel', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.sel.value).toBe('')
|
||||
})
|
||||
|
||||
it('drops an unmapped workflow_input tool when clearUnmapped is set', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [
|
||||
{ type: 'workflow_input', params: { workflowId: 'wf-unknown' } },
|
||||
{ type: 'workflow_input', params: { workflowId: 'sub-src' } },
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
const tools = result.tools.value as Array<{ params?: { workflowId?: string } }>
|
||||
expect(tools).toHaveLength(1)
|
||||
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
||||
})
|
||||
|
||||
it('clears the sibling inputMapping when an unmapped workflow selector is cleared (U10)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowId.value).toBe('')
|
||||
expect(result.inputMapping.value).toBe('')
|
||||
})
|
||||
|
||||
it('keeps inputMapping when the workflow selector is remapped (not cleared)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowId.value).toBe('wf-dst')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
// The `inputMapping` belongs to the ACTIVE canonical mode's workflow only. resolveCanonicalMode
|
||||
// picks the active mode (block.data.canonicalModes override, else the value heuristic); the wipe
|
||||
// fires iff the ACTIVE mode's workflow was removed by the remap. Only the SELECTOR is ever
|
||||
// remapped/cleared - the manual member passes through verbatim - so an active-advanced (manual)
|
||||
// mode never wipes inputMapping. clearUnmapped: true throughout.
|
||||
it('keeps inputMapping: active basic valid + dormant advanced manual preserved (no override)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowId.value).toBe('wf-dst')
|
||||
// Manual member is user-owned: preserved verbatim (never cleared), even while dormant.
|
||||
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
it('keeps inputMapping: active advanced manual preserved (canonicalModes override) + dormant basic remapped', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, {
|
||||
clearUnmapped: true,
|
||||
canonicalModes: { workflowId: 'advanced' },
|
||||
})
|
||||
// Active advanced manual is preserved, so its inputMapping survives; the dormant basic selector
|
||||
// still remaps.
|
||||
expect(result.workflowId.value).toBe('wf-dst')
|
||||
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
it('wipes inputMapping: active basic selector cleared (heuristic) + dormant advanced manual preserved', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-unknown' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowId.value).toBe('')
|
||||
// Manual preserved verbatim (not remapped to wf-dst); the active basic selector clearing is what
|
||||
// wipes inputMapping.
|
||||
expect(result.manualWorkflowId.value).toBe('wf-src')
|
||||
expect(result.inputMapping.value).toBe('')
|
||||
})
|
||||
|
||||
it('keeps inputMapping: active advanced manual preserved + basic empty (heuristic)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-unknown' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.manualWorkflowId.value).toBe('wf-unknown')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
it('keeps inputMapping: both modes valid (selector remapped, manual preserved)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowId.value).toBe('wf-dst')
|
||||
expect(result.manualWorkflowId.value).toBe('sub-src')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
it('does not remap the advanced manualWorkflowId (manual is user-owned)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.manualWorkflowId.value).toBe('wf-src')
|
||||
})
|
||||
|
||||
it('does not remap the manual comma-separated manualWorkflowIds list (manual is user-owned)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src')
|
||||
})
|
||||
|
||||
it('preserves the manual manualWorkflowIds list verbatim even under clearUnmapped', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
manualWorkflowIds: {
|
||||
id: 'manualWorkflowIds',
|
||||
type: 'short-input',
|
||||
value: 'wf-src,wf-unknown',
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-src,wf-unknown')
|
||||
})
|
||||
|
||||
// The advanced manual field is user-owned: ANY free-form value - env ref, literal id, tag, or
|
||||
// arbitrary text - is preserved verbatim under clearUnmapped (active advanced), and its sibling
|
||||
// inputMapping is never wiped. One passthrough covers every free-form edge case at once.
|
||||
it.each([
|
||||
['env ref', '{{MY_WORKFLOW_ID}}'],
|
||||
['literal source-workspace id', 'wf-unknown'],
|
||||
['block-output tag', '<start.workflowId>'],
|
||||
['arbitrary text', 'not an id at all'],
|
||||
])('preserves a manual %s value and its inputMapping (active advanced)', (_label, value) => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: '' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.manualWorkflowId.value).toBe(value)
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
})
|
||||
|
||||
// The one behavioral change vs. selector handling: a literal source-workspace id typed into the
|
||||
// MANUAL field that WOULD map to a copied target is left AS-IS (not remapped), because manual is
|
||||
// user-owned - while the SELECTOR with the same id still remaps to the copied target.
|
||||
it('leaves a mapped literal id in the manual field as-is while the selector remaps it', () => {
|
||||
const manualSubBlocks: SubBlockRecord = {
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'wf-src' },
|
||||
}
|
||||
expect(
|
||||
remapWorkflowReferencesInSubBlocks(manualSubBlocks, map, { clearUnmapped: true })
|
||||
.manualWorkflowId.value
|
||||
).toBe('wf-src')
|
||||
|
||||
const selectorSubBlocks: SubBlockRecord = {
|
||||
workflowId: { id: 'workflowId', type: 'workflow-selector', value: 'wf-src' },
|
||||
}
|
||||
expect(
|
||||
remapWorkflowReferencesInSubBlocks(selectorSubBlocks, map, { clearUnmapped: true }).workflowId
|
||||
.value
|
||||
).toBe('wf-dst')
|
||||
})
|
||||
|
||||
it('remaps a multi-select workflowSelector array', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
|
||||
})
|
||||
|
||||
it('clears unmapped ids from the structured workflowSelector list under clearUnmapped', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowSelector: {
|
||||
id: 'workflowSelector',
|
||||
type: 'dropdown',
|
||||
value: ['wf-src', 'wf-unknown'],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowSelector.value).toEqual(['wf-dst'])
|
||||
})
|
||||
|
||||
// The sim workspace-event trigger's workflow filter: a multi-select `dropdown` with baseKey
|
||||
// `workflowIds` whose options are workspace workflow ids - a structured (selector-sourced)
|
||||
// list, remapped exactly like `workflowSelector`.
|
||||
it('remaps the workspace-event trigger workflowIds dropdown list', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map)
|
||||
expect(result.workflowIds.value).toEqual(['wf-dst', 'sub-dst'])
|
||||
})
|
||||
|
||||
it('drops unmapped ids from the workflowIds dropdown under clearUnmapped', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowIds: { id: 'workflowIds', type: 'dropdown', value: ['wf-src', 'wf-unknown'] },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowIds.value).toEqual(['wf-dst'])
|
||||
})
|
||||
|
||||
// The TYPE gate: the legacy logs block's `workflowIds` is a free-form short-input (manual,
|
||||
// user-owned), so it must pass through verbatim even though its baseKey matches.
|
||||
it('leaves a short-input workflowIds (legacy logs block, user-owned) untouched under clearUnmapped', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
workflowIds: { id: 'workflowIds', type: 'short-input', value: 'wf-src,wf-unknown' },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.workflowIds.value).toBe('wf-src,wf-unknown')
|
||||
})
|
||||
|
||||
// The baseKey gate: dropdowns whose baseKey is neither `workflowSelector` nor `workflowIds`
|
||||
// (event pickers, status filters, ...) hold non-workflow values and are never rewritten.
|
||||
it('leaves other dropdowns untouched (only workflow-list baseKeys are remapped)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
eventType: { id: 'eventType', type: 'dropdown', value: 'wf-src' },
|
||||
level: { id: 'level', type: 'dropdown', value: ['wf-src'] },
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.eventType.value).toBe('wf-src')
|
||||
expect(result.level.value).toEqual(['wf-src'])
|
||||
})
|
||||
|
||||
// create-fork scopes its workflow id map to the workflows ACTUALLY copied (deployed state loaded).
|
||||
// With BOTH `wf-src` and `sub-src` copied, the SELECTOR varieties remap to the child ids; the
|
||||
// free-form MANUAL varieties (`manualWorkflowId`, `manualWorkflowIds`) are user-owned and pass
|
||||
// through verbatim, even under fork-create's clearUnmapped policy.
|
||||
it('remaps selector varieties and preserves manual varieties when both workflows are copied (clearUnmapped)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-src' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
manualWorkflowId: { id: 'manualWorkflowId', type: 'short-input', value: 'sub-src' },
|
||||
manualWorkflowIds: { id: 'manualWorkflowIds', type: 'short-input', value: 'wf-src, sub-src' },
|
||||
workflowSelector: { id: 'workflowSelector', type: 'dropdown', value: ['wf-src', 'sub-src'] },
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [
|
||||
{ type: 'workflow_input', params: { workflowId: 'sub-src', inputMapping: '{}' } },
|
||||
{ type: 'custom-tool', customToolId: 'ct-1' },
|
||||
],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.selector.value).toBe('wf-dst')
|
||||
expect(result.inputMapping.value).toBe('{"a":"b"}')
|
||||
// Manual varieties pass through verbatim (not remapped to the child ids).
|
||||
expect(result.manualWorkflowId.value).toBe('sub-src')
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-src, sub-src')
|
||||
expect(result.workflowSelector.value).toEqual(['wf-dst', 'sub-dst'])
|
||||
const tools = result.tools.value as Array<{ type: string; params?: { workflowId?: string } }>
|
||||
expect(tools[0].params?.workflowId).toBe('sub-dst')
|
||||
expect(tools[1]).toEqual({ type: 'custom-tool', customToolId: 'ct-1' })
|
||||
})
|
||||
|
||||
// A deployed source workflow whose state failed to load is excluded from the scoped fork map, so a
|
||||
// copied workflow's SELECTOR reference to it clears (never dangles at a never-created child id). The
|
||||
// free-form manual list is user-owned and preserved verbatim.
|
||||
it('clears selector references to a deployed-but-uncopied workflow (manual list preserved)', () => {
|
||||
const subBlocks: SubBlockRecord = {
|
||||
selector: { id: 'selector', type: 'workflow-selector', value: 'wf-uncopied' },
|
||||
inputMapping: { id: 'inputMapping', type: 'input-mapping', value: '{"a":"b"}' },
|
||||
manualWorkflowIds: {
|
||||
id: 'manualWorkflowIds',
|
||||
type: 'short-input',
|
||||
value: 'wf-src,wf-uncopied',
|
||||
},
|
||||
tools: {
|
||||
id: 'tools',
|
||||
type: 'tool-input',
|
||||
value: [{ type: 'workflow_input', params: { workflowId: 'wf-uncopied' } }],
|
||||
},
|
||||
}
|
||||
const result = remapWorkflowReferencesInSubBlocks(subBlocks, map, { clearUnmapped: true })
|
||||
expect(result.selector.value).toBe('')
|
||||
expect(result.inputMapping.value).toBe('')
|
||||
expect(result.manualWorkflowIds.value).toBe('wf-src,wf-uncopied')
|
||||
expect(result.tools.value as unknown[]).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coerceObjectArray', () => {
|
||||
it('returns arrays directly', () => {
|
||||
expect(coerceObjectArray([{ a: 1 }])).toEqual({ array: [{ a: 1 }], wasString: false })
|
||||
})
|
||||
it('parses JSON-string arrays', () => {
|
||||
expect(coerceObjectArray('[{"a":1}]')).toEqual({ array: [{ a: 1 }], wasString: true })
|
||||
})
|
||||
it('returns null for non-array values', () => {
|
||||
expect(coerceObjectArray('hi')).toEqual({ array: null, wasString: false })
|
||||
expect(coerceObjectArray(42)).toEqual({ array: null, wasString: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { remapConditionBlockIds } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
type CanonicalModeOverrides,
|
||||
resolveCanonicalMode,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { SYSTEM_SUBBLOCK_IDS, TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const logger = createLogger('WorkflowRemapInternalIds')
|
||||
|
||||
/**
|
||||
* Untrusted shape of a persisted block subBlocks JSON column. Callers narrow
|
||||
* `type`/`value` with runtime checks before mutating; the index signature exists
|
||||
* because the raw record is handed back to drizzle without knowing which subBlock
|
||||
* keys it contains.
|
||||
*/
|
||||
export type SubBlockRecord = Record<
|
||||
string,
|
||||
{ type?: unknown; value?: unknown; [key: string]: unknown }
|
||||
>
|
||||
|
||||
type VariableAssignment = Record<string, unknown> & { variableId?: unknown }
|
||||
|
||||
const DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS = new Set(
|
||||
SYSTEM_SUBBLOCK_IDS.filter((id) => id !== 'triggerCredentials')
|
||||
)
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
}
|
||||
|
||||
/** Coerce a subblock value that holds a JSON array (stored as an array or a JSON string). */
|
||||
export function coerceObjectArray(value: unknown): { array: unknown[] | null; wasString: boolean } {
|
||||
if (Array.isArray(value)) return { array: value, wasString: false }
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) return { array: parsed, wasString: true }
|
||||
} catch {}
|
||||
}
|
||||
return { array: null, wasString: false }
|
||||
}
|
||||
|
||||
export function isSystemSubBlockKey(key: string, ids: Set<string> | string[]): boolean {
|
||||
const idList = Array.isArray(ids) ? ids : Array.from(ids)
|
||||
return idList.some((id) => key === id || key.startsWith(`${id}_`))
|
||||
}
|
||||
|
||||
/** Strip trigger-runtime and non-credential system subblocks for a fresh copy. */
|
||||
export function sanitizeSubBlocksForDuplicate(subBlocks: SubBlockRecord): SubBlockRecord {
|
||||
const sanitized: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (isSystemSubBlockKey(key, TRIGGER_RUNTIME_SUBBLOCK_IDS)) continue
|
||||
if (isSystemSubBlockKey(key, DUPLICATE_STRIPPED_SYSTEM_SUBBLOCK_IDS)) continue
|
||||
sanitized[key] = subBlock
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function remapVariableAssignment(value: unknown, varIdMap: Map<string, string>): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => remapVariableAssignment(item, varIdMap))
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return value
|
||||
}
|
||||
const assignment = value as VariableAssignment
|
||||
const next: Record<string, unknown> = {}
|
||||
for (const [key, nestedValue] of Object.entries(assignment)) {
|
||||
next[key] = remapVariableAssignment(nestedValue, varIdMap)
|
||||
}
|
||||
if (typeof assignment.variableId === 'string') {
|
||||
const newVarId = varIdMap.get(assignment.variableId)
|
||||
if (newVarId) {
|
||||
next.variableId = newVarId
|
||||
} else {
|
||||
logger.warn('Skipping unknown variable reference during copy', {
|
||||
variableId: assignment.variableId,
|
||||
})
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function remapVariableInputValue(value: unknown, varIdMap: Map<string, string>): unknown {
|
||||
if (value == null) {
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return remapVariableAssignment(value, varIdMap)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return value
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(trimmed)
|
||||
} catch {
|
||||
throw new Error('Variables input assignments could not be parsed for copy')
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return remapVariableAssignment(parsed, varIdMap)
|
||||
}
|
||||
throw new Error('Variables input assignments must be an array')
|
||||
}
|
||||
throw new Error('Variables input assignments must be an array')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap old variable IDs to new variable IDs inside block subBlocks, targeting
|
||||
* `variables-input` subblocks whose value is an array of variable assignments.
|
||||
*/
|
||||
export function remapVariableIdsInSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
varIdMap: Map<string, string>
|
||||
): SubBlockRecord {
|
||||
const updated: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (subBlock && typeof subBlock === 'object' && subBlock.type === 'variables-input') {
|
||||
updated[key] = {
|
||||
...subBlock,
|
||||
value: remapVariableInputValue(subBlock.value, varIdMap),
|
||||
}
|
||||
} else {
|
||||
updated[key] = subBlock
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite cross-workflow references through a workflow id map. Only SELECTOR-sourced (structured)
|
||||
* references are remapped/cleared: the basic `workflow-selector` value, the multi-select
|
||||
* `workflowSelector` list (logs block), the workspace-event trigger's multi-select `workflowIds`
|
||||
* dropdown (its options are workspace workflow ids), and `workflow_input` sub-workflow tools
|
||||
* nested in a `tool-input` array (an agent picking another workflow as a tool - its
|
||||
* `params.workflowId` comes from the workflow picker, never free-form input).
|
||||
*
|
||||
* The advanced, free-form MANUAL fields (`manualWorkflowId`, comma-separated `manualWorkflowIds`)
|
||||
* are user-owned and pass through VERBATIM - mirroring `manualCredential` in the fork remap - so a
|
||||
* hand-typed value (an env ref `{{VAR}}`, a `<block.output>` tag, a literal id, or arbitrary text)
|
||||
* is never rewritten or cleared. The `workflowIds` handling is gated on subblock TYPE `dropdown`
|
||||
* for the same reason: the legacy logs block's `workflowIds` is a free-form `short-input`
|
||||
* (user-owned, verbatim), and only the workspace-event trigger uses a `workflowIds` dropdown.
|
||||
*
|
||||
* `clearUnmapped` controls the cross-workspace case for those selector references: fork/promote pass
|
||||
* `true` so a selector pointing at a workflow that wasn't copied is cleared/dropped rather than left
|
||||
* pointing at the source workspace (a silent cross-workspace execution). Same-workspace duplication
|
||||
* leaves it `false` to preserve references to untouched sibling workflows.
|
||||
*/
|
||||
export function remapWorkflowReferencesInSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
workflowIdMap: Map<string, string> | undefined,
|
||||
options?: { clearUnmapped?: boolean; canonicalModes?: CanonicalModeOverrides }
|
||||
): SubBlockRecord {
|
||||
if (!workflowIdMap?.size) return subBlocks
|
||||
const clearUnmapped = options?.clearUnmapped ?? false
|
||||
const remapScalar = (value: string): string => {
|
||||
const mapped = workflowIdMap.get(value)
|
||||
if (mapped) return mapped
|
||||
return clearUnmapped ? '' : value
|
||||
}
|
||||
// The `workflowId` canonical pair: basic `workflow-selector` + advanced `manualWorkflowId`. Capture
|
||||
// each key (by type/baseKey, regardless of value) and its ORIGINAL value so the inputMapping wipe
|
||||
// below can decide on the ACTIVE mode's disposition via `resolveCanonicalMode`. Only the basic
|
||||
// selector is ever remapped; the advanced manual member is captured for mode resolution only.
|
||||
let basicId: string | undefined
|
||||
let basicValue = ''
|
||||
let advancedId: string | undefined
|
||||
let advancedValue = ''
|
||||
const updated: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (subBlock && typeof subBlock === 'object') {
|
||||
const baseKey = key.replace(/_\d+$/, '')
|
||||
if (subBlock.type === 'workflow-selector' && basicId === undefined) {
|
||||
basicId = key
|
||||
basicValue = typeof subBlock.value === 'string' ? subBlock.value : ''
|
||||
} else if (baseKey === 'manualWorkflowId' && advancedId === undefined) {
|
||||
advancedId = key
|
||||
advancedValue = typeof subBlock.value === 'string' ? subBlock.value : ''
|
||||
}
|
||||
// Remap only the SELECTOR member; the manual `manualWorkflowId` passes through verbatim.
|
||||
if (
|
||||
subBlock.type === 'workflow-selector' &&
|
||||
typeof subBlock.value === 'string' &&
|
||||
subBlock.value
|
||||
) {
|
||||
updated[key] = { ...subBlock, value: remapScalar(subBlock.value) }
|
||||
continue
|
||||
}
|
||||
// Remap only the STRUCTURED multi-workflow lists: the logs block's `workflowSelector` and
|
||||
// the workspace-event trigger's `workflowIds` dropdown. The latter is gated on TYPE
|
||||
// `dropdown` so the legacy logs block's `workflowIds` short-input (manual, user-owned)
|
||||
// passes through verbatim, as does the manual comma-separated `manualWorkflowIds`.
|
||||
if (
|
||||
baseKey === 'workflowSelector' ||
|
||||
(subBlock.type === 'dropdown' && baseKey === 'workflowIds')
|
||||
) {
|
||||
const remapped = remapWorkflowIdList(subBlock.value, workflowIdMap, clearUnmapped)
|
||||
if (remapped !== subBlock.value) {
|
||||
updated[key] = { ...subBlock, value: remapped }
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (subBlock.type === 'tool-input') {
|
||||
const remapped = remapWorkflowInputTools(subBlock.value, workflowIdMap, clearUnmapped)
|
||||
if (remapped !== subBlock.value) {
|
||||
updated[key] = { ...subBlock, value: remapped }
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
if (basicId !== undefined || advancedId !== undefined) {
|
||||
const isEmptyValue = (value: unknown) => value === '' || value == null
|
||||
const values: Record<string, unknown> = {}
|
||||
if (basicId !== undefined) values[basicId] = basicValue
|
||||
if (advancedId !== undefined) values[advancedId] = advancedValue
|
||||
const activeMode = resolveCanonicalMode(
|
||||
{ canonicalId: 'workflowId', basicId, advancedIds: advancedId ? [advancedId] : [] },
|
||||
values,
|
||||
options?.canonicalModes
|
||||
)
|
||||
const activeKey = activeMode === 'advanced' ? advancedId : basicId
|
||||
const originalActive = activeKey === basicId ? basicValue : advancedValue
|
||||
const postActive = activeKey !== undefined ? updated[activeKey]?.value : undefined
|
||||
if (activeKey !== undefined && !isEmptyValue(originalActive) && isEmptyValue(postActive)) {
|
||||
for (const [key, subBlock] of Object.entries(updated)) {
|
||||
if (key.replace(/_\d+$/, '') !== 'inputMapping') continue
|
||||
if (!subBlock || typeof subBlock !== 'object') continue
|
||||
if (subBlock.value === '' || subBlock.value == null) continue
|
||||
updated[key] = { ...subBlock, value: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a multi-workflow value (comma-separated string or array of workflow ids)
|
||||
* through a workflow id map. Unmapped ids are dropped when `clearUnmapped` is set
|
||||
* (cross-workspace) and preserved otherwise. Returns the original reference when
|
||||
* nothing changed.
|
||||
*/
|
||||
function remapWorkflowIdList(
|
||||
value: unknown,
|
||||
workflowIdMap: Map<string, string>,
|
||||
clearUnmapped: boolean
|
||||
): unknown {
|
||||
const remapId = (id: string): string | null => {
|
||||
const mapped = workflowIdMap.get(id)
|
||||
if (mapped) return mapped
|
||||
return clearUnmapped ? null : id
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false
|
||||
const next: unknown[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item !== 'string' || !item) {
|
||||
next.push(item)
|
||||
continue
|
||||
}
|
||||
const mapped = remapId(item)
|
||||
if (mapped === null) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (mapped !== item) changed = true
|
||||
next.push(mapped)
|
||||
}
|
||||
return changed ? next : value
|
||||
}
|
||||
if (typeof value === 'string' && value) {
|
||||
const next: string[] = []
|
||||
for (const id of value.split(',').map((entry) => entry.trim())) {
|
||||
if (!id) continue
|
||||
const mapped = remapId(id)
|
||||
if (mapped !== null) next.push(mapped)
|
||||
}
|
||||
const joined = next.join(',')
|
||||
return joined === value ? value : joined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite `workflow_input` tools' `params.workflowId` through a workflow id map.
|
||||
* When `clearUnmapped` is set, a tool pointing at a workflow that wasn't copied is
|
||||
* dropped (it can't be referenced cross-workspace).
|
||||
*/
|
||||
function remapWorkflowInputTools(
|
||||
value: unknown,
|
||||
workflowIdMap: Map<string, string>,
|
||||
clearUnmapped: boolean
|
||||
): unknown {
|
||||
const { array, wasString } = coerceObjectArray(value)
|
||||
if (!array) return value
|
||||
let changed = false
|
||||
const next = array.flatMap((tool) => {
|
||||
if (!isRecord(tool) || tool.type !== 'workflow_input' || !isRecord(tool.params)) return [tool]
|
||||
const workflowId = tool.params.workflowId
|
||||
if (typeof workflowId !== 'string') return [tool]
|
||||
const mapped = workflowIdMap.get(workflowId)
|
||||
if (mapped) {
|
||||
if (mapped === workflowId) return [tool]
|
||||
changed = true
|
||||
return [{ ...tool, params: { ...tool.params, workflowId: mapped } }]
|
||||
}
|
||||
if (clearUnmapped) {
|
||||
changed = true
|
||||
return []
|
||||
}
|
||||
return [tool]
|
||||
})
|
||||
if (!changed) return value
|
||||
return wasString ? JSON.stringify(next) : next
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap condition/router block IDs within subBlocks when a block is copied with
|
||||
* a new ID. Returns a new object without mutating the input.
|
||||
*/
|
||||
export function remapConditionIdsInSubBlocks(
|
||||
subBlocks: SubBlockRecord,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): SubBlockRecord {
|
||||
const updated: SubBlockRecord = {}
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
|
||||
typeof subBlock.value === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(subBlock.value)
|
||||
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
||||
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
|
||||
continue
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
updated[key] = subBlock
|
||||
}
|
||||
return updated
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user