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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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