chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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

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
+55
View File
@@ -0,0 +1,55 @@
import { createLogger } from '@sim/logger'
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('Workflows')
/**
* Get a workflow with its state merged in by ID
* Note: Since localStorage has been removed, this only works for the active workflow
* @param workflowId ID of the workflow to retrieve
* @param workspaceId Workspace containing the workflow metadata
* @returns The workflow with merged state values or null if not found/not active
*/
export function getWorkflowWithValues(workflowId: string, workspaceId: string) {
const workflows = getWorkflows(workspaceId)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const metadata = workflows.find((w) => w.id === workflowId)
if (!metadata) {
logger.warn(`Workflow ${workflowId} not found`)
return null
}
// Since localStorage persistence has been removed, only return data for active workflow
if (workflowId !== activeWorkflowId) {
logger.warn(`Cannot get state for non-active workflow ${workflowId} - localStorage removed`)
return null
}
// Use the current state from the store (only available for active workflow)
const workflowState: WorkflowState = useWorkflowStore.getState().getWorkflowState()
// Merge the subblock values for this specific workflow
const mergedBlocks = mergeSubblockState(workflowState.blocks, workflowId)
return {
id: workflowId,
name: metadata.name,
description: metadata.description,
workspaceId: metadata.workspaceId,
folderId: metadata.folderId,
state: {
blocks: mergedBlocks,
edges: workflowState.edges,
loops: workflowState.loops,
parallels: workflowState.parallels,
lastSaved: workflowState.lastSaved,
},
}
}
export type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -0,0 +1,211 @@
/**
* @vitest-environment node
*
* Focused tests for the registry store's `loadWorkflowState` after the
* workflow-state cache collapse: it hydrates the shared
* `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh,
* `staleTime: 0`) and projects the envelope into the workflow / sub-block /
* variables / deployment stores, guarding against superseded responses.
*/
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({
mockRequestJson: vi.fn(),
sharedQueryClient: { current: null as unknown },
}))
vi.mock('@/lib/api/client/request', () => ({
requestJson: mockRequestJson,
}))
vi.mock('@/app/_shell/providers/get-query-client', () => ({
getQueryClient: () => sharedQueryClient.current as QueryClient,
}))
const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted(
() => ({
replaceWorkflowState: vi.fn(),
initializeFromWorkflow: vi.fn(),
setVariablesState: vi.fn(),
clearError: vi.fn(),
})
)
vi.mock('@/stores/workflows/workflow/store', () => ({
useWorkflowStore: {
getState: () => ({ replaceWorkflowState, blocks: {} }),
setState: vi.fn(),
},
}))
vi.mock('@/stores/workflows/subblock/store', () => ({
useSubBlockStore: {
getState: () => ({ initializeFromWorkflow }),
setState: vi.fn(),
},
}))
vi.mock('@/stores/variables/store', () => ({
useVariablesStore: {
getState: () => ({ variables: {} }),
setState: (updater: unknown) => setVariablesState(updater),
},
}))
vi.mock('@/stores/operation-queue/store', () => ({
useOperationQueueStore: {
getState: () => ({ clearError }),
},
}))
vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({
invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/stores/workflows/utils', () => ({
getUniqueBlockName: vi.fn(),
regenerateBlockIds: vi.fn(),
}))
vi.mock('@/lib/workflows/autolayout/constants', () => ({
DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 },
}))
vi.mock('@/hooks/queries/deployments', () => ({
deploymentKeys: {
infos: () => ['deployments', 'info'],
info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''],
},
}))
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
function makeEnvelope(overrides: Record<string, unknown> = {}) {
return {
id: 'wf-1',
isDeployed: true,
deployedAt: new Date('2026-01-01T00:00:00.000Z'),
isPublicApi: false,
state: {
blocks: { b1: { id: 'b1' } },
edges: [],
loops: {},
parallels: {},
},
variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } },
...overrides,
}
}
describe('registry store loadWorkflowState (collapsed cache)', () => {
beforeEach(() => {
vi.clearAllMocks()
// The store dispatches an `active-workflow-changed` CustomEvent on the
// window; provide a minimal stub under the node environment.
vi.stubGlobal('window', { dispatchEvent: vi.fn() })
sharedQueryClient.current = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
// Reset store to a clean state with a workspace scope so loadWorkflowState
// does not bail on the missing-workspace guard.
useWorkflowRegistry.setState({
activeWorkflowId: null,
error: null,
hydration: {
phase: 'idle',
workspaceId: 'ws-1',
workflowId: null,
requestId: null,
error: null,
},
})
})
it('projects envelope state, variables, and deployment info into the stores', async () => {
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
expect(replaceWorkflowState).toHaveBeenCalledTimes(1)
expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({
currentWorkflowId: 'wf-1',
blocks: { b1: { id: 'b1' } },
edges: [],
})
expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } })
expect(setVariablesState).toHaveBeenCalledTimes(1)
const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([
'deployments',
'info',
'wf-1',
])
expect(deploymentInfo).toMatchObject({
isDeployed: true,
isPublicApi: false,
deployedAt: '2026-01-01T00:00:00.000Z',
})
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1')
expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready')
})
it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => {
const envelope = makeEnvelope()
mockRequestJson.mockResolvedValue({ data: envelope })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
const client = sharedQueryClient.current as QueryClient
const cached = client.getQueryData(workflowKeys.state('wf-1'))
expect(cached).toBeDefined()
expect((cached as { id: string }).id).toBe('wf-1')
// Exactly one cache entry exists for this endpoint — the shared one.
const stateEntries = client
.getQueryCache()
.findAll({ queryKey: workflowKeys.states() })
.filter((q) => q.queryKey[2] === 'wf-1')
expect(stateEntries).toHaveLength(1)
})
it('re-fetches on every call (staleTime: 0, never served stale)', async () => {
mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
expect(mockRequestJson).toHaveBeenCalledTimes(2)
})
it('discards a superseded response via the staleness guard', async () => {
// First load (wf-1) is in-flight; a second load (wf-2) supersedes the
// hydration workflowId, then wf-1 finally resolves. The guard compares the
// current hydration workflowId/requestId against the resolving request and
// must discard the now-stale wf-1 projection.
let resolveFirst: (value: unknown) => void = () => {}
const firstPending = new Promise((resolve) => {
resolveFirst = resolve
})
mockRequestJson
.mockImplementationOnce(() => firstPending)
.mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) }))
const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1')
const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2')
await secondLoad
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
const projectionsAfterSecond = replaceWorkflowState.mock.calls.length
resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) })
await firstLoad
// The stale wf-1 result must not project again — hydration is now wf-2.
expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
})
})
+419
View File
@@ -0,0 +1,419 @@
import { createLogger } from '@sim/logger'
import { generateRandomHex } from '@sim/utils/random'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
import type { HydrationState, WorkflowRegistry } from '@/stores/workflows/registry/types'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, WorkflowState } from '@/stores/workflows/workflow/types'
const logger = createLogger('WorkflowRegistry')
const initialHydration: HydrationState = {
phase: 'idle',
workspaceId: null,
workflowId: null,
requestId: null,
error: null,
}
const createRequestId = () => `${Date.now()}-${generateRandomHex(8)}`
function resetWorkflowStores() {
useWorkflowStore.setState({
currentWorkflowId: null,
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
})
useSubBlockStore.setState({
workflowValues: {},
})
}
export const useWorkflowRegistry = create<WorkflowRegistry>()(
devtools(
(set, get) => ({
activeWorkflowId: null,
error: null,
hydration: initialHydration,
clipboard: null,
pendingSelection: null,
switchToWorkspace: (workspaceId: string) => {
logger.info(`Switching to workspace: ${workspaceId}`)
resetWorkflowStores()
// Workflow stores are fully reset and reloaded from the server in the new
// workspace, so a previously tripped offline mode must not carry over.
useOperationQueueStore.getState().clearError()
void invalidateWorkflowLists(getQueryClient(), workspaceId)
set({
activeWorkflowId: null,
error: null,
hydration: {
phase: 'idle',
workspaceId,
workflowId: null,
requestId: null,
error: null,
},
})
},
loadWorkflowState: async (workflowId: string) => {
const workspaceId = get().hydration.workspaceId
if (!workspaceId) {
const message = `Cannot load workflow ${workflowId} without a workspace scope`
logger.error(message)
set({ error: message })
throw new Error(message)
}
const requestId = createRequestId()
set((state) => ({
error: null,
hydration: {
phase: 'state-loading',
workspaceId: workspaceId ?? state.hydration.workspaceId,
workflowId,
requestId,
error: null,
},
}))
try {
const workflowData = await getQueryClient().fetchQuery({
queryKey: workflowKeys.state(workflowId),
queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal),
staleTime: 0,
})
const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null
getQueryClient().setQueryData<WorkflowDeploymentInfo>(
deploymentKeys.info(workflowId),
(prev) => ({
isDeployed: workflowData.isDeployed,
deployedAt,
apiKey: prev?.apiKey ?? null,
needsRedeployment: prev?.needsRedeployment ?? false,
isPublicApi: workflowData.isPublicApi,
})
)
let workflowState: WorkflowState
if (workflowData?.state) {
const wireState = workflowData.state as Pick<
WorkflowState,
'blocks' | 'edges' | 'loops' | 'parallels'
>
workflowState = {
currentWorkflowId: workflowId,
blocks: wireState.blocks || {},
edges: wireState.edges || [],
loops: wireState.loops || {},
parallels: wireState.parallels || {},
lastSaved: Date.now(),
}
} else {
workflowState = {
currentWorkflowId: workflowId,
blocks: {},
edges: [],
loops: {},
parallels: {},
lastSaved: Date.now(),
}
logger.info(
`Workflow ${workflowId} has no state yet - will load from DB or show empty canvas`
)
}
const currentHydration = get().hydration
if (
currentHydration.requestId !== requestId ||
currentHydration.workflowId !== workflowId
) {
logger.info('Discarding stale workflow hydration result', {
workflowId,
requestId,
})
return
}
useWorkflowStore.getState().replaceWorkflowState(workflowState)
useSubBlockStore.getState().initializeFromWorkflow(workflowId, workflowState.blocks || {})
const wireVariables = workflowData.variables
if (wireVariables) {
useVariablesStore.setState((state) => {
const withoutWorkflow = Object.fromEntries(
Object.entries(state.variables).filter(
(entry): entry is [string, Variable] => entry[1].workflowId !== workflowId
)
)
return {
variables: {
...withoutWorkflow,
...(wireVariables as Record<string, Variable>),
},
}
})
}
window.dispatchEvent(
new CustomEvent('active-workflow-changed', {
detail: { workflowId },
})
)
set((state) => ({
activeWorkflowId: workflowId,
error: null,
hydration: {
phase: 'ready',
workspaceId: state.hydration.workspaceId,
workflowId,
requestId,
error: null,
},
}))
logger.info(`Switched to workflow ${workflowId}`)
} catch (error) {
const message =
error instanceof Error
? error.message
: `Failed to load workflow ${workflowId}: Unknown error`
logger.error(message)
const currentHydration = get().hydration
if (
currentHydration.requestId !== requestId ||
currentHydration.workflowId !== workflowId
) {
logger.info('Discarding stale workflow error', { workflowId, requestId })
return
}
set((state) => ({
error: message,
hydration: {
phase: 'error',
workspaceId: state.hydration.workspaceId,
workflowId,
requestId: null,
error: message,
},
}))
throw error
}
},
setActiveWorkflow: async (id: string) => {
const { activeWorkflowId, hydration } = get()
const workflowStoreState = useWorkflowStore.getState()
const hasWorkflowData = Object.keys(workflowStoreState.blocks).length > 0
const isFullyHydrated =
activeWorkflowId === id &&
hasWorkflowData &&
hydration.phase === 'ready' &&
hydration.workflowId === id
if (isFullyHydrated) {
logger.info(`Already active workflow ${id} with data loaded, skipping switch`)
return
}
await get().loadWorkflowState(id)
},
markWorkflowCreating: (workflowId: string) => {
set((state) => ({
error: null,
hydration: {
phase: 'creating' as const,
workspaceId: state.hydration.workspaceId,
workflowId,
requestId: null,
error: null,
},
}))
logger.info(`Marked workflow ${workflowId} as creating`)
},
markWorkflowCreated: (workflowId: string | null) => {
const { hydration } = get()
if (!workflowId) {
if (hydration.phase === 'creating') {
set((state) => ({
hydration: {
...state.hydration,
phase: 'idle' as const,
workflowId: null,
error: null,
},
}))
}
return
}
if (hydration.phase !== 'creating' || hydration.workflowId !== workflowId) {
logger.info(
`Ignoring markWorkflowCreated for ${workflowId} — hydration is ${hydration.phase}/${hydration.workflowId}`
)
return
}
logger.info(`Workflow ${workflowId} created, loading state`)
get()
.loadWorkflowState(workflowId)
.catch((error) => {
logger.error(`Failed to load newly created workflow ${workflowId}:`, error)
})
},
logout: () => {
logger.info('Logging out - clearing all workflow data')
resetWorkflowStores()
// Clear the React Query cache to remove all server state
getQueryClient().clear()
set({
activeWorkflowId: null,
error: null,
hydration: initialHydration,
clipboard: null,
})
logger.info('Logout complete - all workflow data cleared')
},
copyBlocks: (blockIds: string[]) => {
if (blockIds.length === 0) return
const workflowStore = useWorkflowStore.getState()
const activeWorkflowId = get().activeWorkflowId
const subBlockStore = useSubBlockStore.getState()
const copiedBlocks: Record<string, BlockState> = {}
const copiedSubBlockValues: Record<string, Record<string, unknown>> = {}
const blockIdSet = new Set(blockIds)
blockIds.forEach((blockId) => {
const loop = workflowStore.loops[blockId]
if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n))
const parallel = workflowStore.parallels[blockId]
if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n))
})
blockIdSet.forEach((blockId) => {
const block = workflowStore.blocks[blockId]
if (block) {
copiedBlocks[blockId] = structuredClone(block)
if (activeWorkflowId) {
const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId]
if (blockValues) {
copiedSubBlockValues[blockId] = structuredClone(blockValues)
}
}
}
})
const copiedEdges = workflowStore.edges.filter(
(edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target)
)
const copiedLoops: Record<string, Loop> = {}
Object.entries(workflowStore.loops).forEach(([loopId, loop]) => {
if (blockIdSet.has(loopId)) {
copiedLoops[loopId] = structuredClone(loop)
}
})
const copiedParallels: Record<string, Parallel> = {}
Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => {
if (blockIdSet.has(parallelId)) {
copiedParallels[parallelId] = structuredClone(parallel)
}
})
set({
clipboard: {
blocks: copiedBlocks,
edges: copiedEdges,
subBlockValues: copiedSubBlockValues,
loops: copiedLoops,
parallels: copiedParallels,
timestamp: Date.now(),
},
})
logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length })
},
preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => {
const { clipboard, activeWorkflowId } = get()
if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null
if (!activeWorkflowId) return null
const workflowStore = useWorkflowStore.getState()
const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds(
clipboard.blocks,
clipboard.edges,
clipboard.loops,
clipboard.parallels,
clipboard.subBlockValues,
positionOffset,
workflowStore.blocks,
getUniqueBlockName
)
return { blocks, edges, loops, parallels, subBlockValues }
},
hasClipboard: () => {
const { clipboard } = get()
return clipboard !== null && Object.keys(clipboard.blocks).length > 0
},
clearClipboard: () => {
set({ clipboard: null })
},
setPendingSelection: (blockIds: string[]) => {
set((state) => ({
pendingSelection: [...(state.pendingSelection ?? []), ...blockIds],
}))
},
clearPendingSelection: () => {
set({ pendingSelection: null })
},
}),
{ name: 'workflow-registry' }
)
)
@@ -0,0 +1,65 @@
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
interface ClipboardData {
blocks: Record<string, BlockState>
edges: Edge[]
subBlockValues: Record<string, Record<string, unknown>>
loops: Record<string, Loop>
parallels: Record<string, Parallel>
timestamp: number
}
export interface WorkflowMetadata {
id: string
name: string
lastModified: Date
createdAt: Date
description?: string
workspaceId?: string
folderId?: string | null
sortOrder: number
archivedAt?: Date | null
locked?: boolean
}
export type HydrationPhase = 'idle' | 'creating' | 'state-loading' | 'ready' | 'error'
export interface HydrationState {
phase: HydrationPhase
workspaceId: string | null
workflowId: string | null
requestId: string | null
error: string | null
}
interface WorkflowRegistryState {
activeWorkflowId: string | null
error: string | null
hydration: HydrationState
clipboard: ClipboardData | null
pendingSelection: string[] | null
}
interface WorkflowRegistryActions {
setActiveWorkflow: (id: string) => Promise<void>
loadWorkflowState: (workflowId: string) => Promise<void>
switchToWorkspace: (id: string) => void
markWorkflowCreating: (workflowId: string) => void
markWorkflowCreated: (workflowId: string | null) => void
copyBlocks: (blockIds: string[]) => void
preparePasteData: (positionOffset?: { x: number; y: number }) => {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
subBlockValues: Record<string, Record<string, unknown>>
} | null
hasClipboard: () => boolean
clearClipboard: () => void
setPendingSelection: (blockIds: string[]) => void
clearPendingSelection: () => void
logout: () => void
}
export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions
+420
View File
@@ -0,0 +1,420 @@
import { randomItem } from '@sim/utils/random'
// Cosmos-themed adjectives and nouns for creative workflow names (max 9 chars each)
const ADJECTIVES = [
// Light & Luminosity
'Radiant',
'Luminous',
'Blazing',
'Glowing',
'Bright',
'Gleaming',
'Shining',
'Lustrous',
'Flaring',
'Vivid',
'Dazzling',
'Beaming',
'Brilliant',
'Lit',
'Ablaze',
// Celestial Descriptors
'Stellar',
'Cosmic',
'Astral',
'Galactic',
'Nebular',
'Orbital',
'Lunar',
'Solar',
'Starlit',
'Heavenly',
'Celestial',
'Sidereal',
'Planetary',
'Starry',
'Spacial',
// Scale & Magnitude
'Infinite',
'Vast',
'Boundless',
'Immense',
'Colossal',
'Titanic',
'Massive',
'Grand',
'Supreme',
'Ultimate',
'Epic',
'Enormous',
'Gigantic',
'Limitless',
'Total',
// Temporal
'Eternal',
'Ancient',
'Timeless',
'Enduring',
'Ageless',
'Immortal',
'Primal',
'Nascent',
'First',
'Elder',
'Lasting',
'Undying',
'Perpetual',
'Final',
'Prime',
// Movement & Energy
'Sidbuck',
'Swift',
'Drifting',
'Spinning',
'Surging',
'Pulsing',
'Soaring',
'Racing',
'Falling',
'Rising',
'Circling',
'Streaking',
'Hurtling',
'Floating',
'Orbiting',
'Spiraling',
// Colors of Space
'Crimson',
'Azure',
'Violet',
'Indigo',
'Amber',
'Sapphire',
'Obsidian',
'Silver',
'Golden',
'Scarlet',
'Cobalt',
'Emerald',
'Ruby',
'Onyx',
'Ivory',
// Physical Properties
'Magnetic',
'Quantum',
'Thermal',
'Photonic',
'Ionic',
'Plasma',
'Spectral',
'Charged',
'Polar',
'Dense',
'Atomic',
'Nuclear',
'Electric',
'Kinetic',
'Static',
// Atmosphere & Mystery
'Ethereal',
'Mystic',
'Phantom',
'Shadow',
'Silent',
'Distant',
'Hidden',
'Veiled',
'Fading',
'Arcane',
'Cryptic',
'Obscure',
'Dim',
'Dusky',
'Shrouded',
// Temperature & State
'Frozen',
'Burning',
'Molten',
'Volatile',
'Icy',
'Fiery',
'Cool',
'Warm',
'Cold',
'Hot',
'Searing',
'Frigid',
'Scalding',
'Chilled',
'Heated',
// Power & Force
'Mighty',
'Fierce',
'Raging',
'Wild',
'Serene',
'Tranquil',
'Harmonic',
'Resonant',
'Steady',
'Bold',
'Potent',
'Violent',
'Calm',
'Furious',
'Forceful',
// Texture & Form
'Smooth',
'Jagged',
'Fractured',
'Solid',
'Hollow',
'Curved',
'Sharp',
'Fluid',
'Rigid',
'Warped',
// Rare & Precious
'Noble',
'Pure',
'Rare',
'Pristine',
'Flawless',
'Unique',
'Exotic',
'Sacred',
'Divine',
'Hallowed',
]
const NOUNS = [
// Stars & Stellar Objects
'Star',
'Sun',
'Pulsar',
'Quasar',
'Magnetar',
'Nova',
'Supernova',
'Hypernova',
'Neutron',
'Dwarf',
'Giant',
'Protostar',
'Blazar',
'Cepheid',
'Binary',
// Galaxies & Clusters
'Galaxy',
'Nebula',
'Cluster',
'Void',
'Filament',
'Halo',
'Bulge',
'Spiral',
'Ellipse',
'Arm',
'Disk',
'Shell',
'Remnant',
'Cloud',
'Dust',
// Planets & Moons
'Planet',
'Moon',
'World',
'Exoplanet',
'Jovian',
'Titan',
'Europa',
'Io',
'Callisto',
'Ganymede',
'Triton',
'Phobos',
'Deimos',
'Enceladus',
'Charon',
// Small Bodies
'Comet',
'Meteor',
'Asteroid',
'Meteorite',
'Bolide',
'Fireball',
'Iceball',
'Plutino',
'Centaur',
'Trojan',
'Shard',
'Fragment',
'Debris',
'Rock',
'Ice',
// Constellations & Myths
'Orion',
'Andromeda',
'Perseus',
'Pegasus',
'Phoenix',
'Draco',
'Cygnus',
'Aquila',
'Lyra',
'Vega',
'Centaurus',
'Hydra',
'Sirius',
'Polaris',
'Altair',
// Celestial Phenomena
'Eclipse',
'Aurora',
'Corona',
'Flare',
'Storm',
'Vortex',
'Jet',
'Burst',
'Pulse',
'Wave',
'Ripple',
'Shimmer',
'Glow',
'Flash',
'Spark',
// Cosmic Structures
'Horizon',
'Zenith',
'Nadir',
'Apex',
'Meridian',
'Equinox',
'Solstice',
'Transit',
'Aphelion',
'Orbit',
'Axis',
'Pole',
'Equator',
'Limb',
'Arc',
// Space & Dimensions
'Cosmos',
'Universe',
'Dimension',
'Realm',
'Expanse',
'Infinity',
'Continuum',
'Manifold',
'Abyss',
'Ether',
'Vacuum',
'Space',
'Fabric',
'Plane',
'Domain',
// Energy & Particles
'Photon',
'Neutrino',
'Proton',
'Electron',
'Positron',
'Quark',
'Boson',
'Fermion',
'Tachyon',
'Graviton',
'Meson',
'Gluon',
'Lepton',
'Muon',
'Pion',
// Regions & Zones
'Sector',
'Quadrant',
'Zone',
'Belt',
'Ring',
'Field',
'Stream',
'Current',
'Wake',
'Region',
'Frontier',
'Border',
'Edge',
'Margin',
'Rim',
// Navigation & Discovery
'Beacon',
'Signal',
'Probe',
'Voyager',
'Pioneer',
'Seeker',
'Wanderer',
'Nomad',
'Drifter',
'Scout',
'Explorer',
'Ranger',
'Surveyor',
'Sentinel',
'Watcher',
// Portals & Passages
'Gateway',
'Portal',
'Nexus',
'Bridge',
'Conduit',
'Channel',
'Passage',
'Rift',
'Warp',
'Fold',
'Tunnel',
'Crossing',
'Link',
'Path',
'Route',
// Core & Systems
'Core',
'Matrix',
'Lattice',
'Network',
'Circuit',
'Array',
'Reactor',
'Engine',
'Forge',
'Crucible',
'Hub',
'Node',
'Kernel',
'Center',
'Heart',
// Cosmic Objects
'Crater',
'Rift',
'Chasm',
'Canyon',
'Peak',
'Ridge',
'Basin',
'Plateau',
'Valley',
'Trench',
]
/**
* Generates a creative workflow name using random adjectives and nouns
* @returns A creative workflow name like "blazing-phoenix" or "crystal-dragon"
*/
export function generateCreativeWorkflowName(): string {
const adjective = randomItem(ADJECTIVES)
const noun = randomItem(NOUNS)
return `${adjective.toLowerCase()}-${noun.toLowerCase()}`
}
+169
View File
@@ -0,0 +1,169 @@
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
import { populateTriggerFieldsFromConfig } from '@/hooks/use-trigger-config-aggregation'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import type { SubBlockStore, SubBlockValue } from '@/stores/workflows/subblock/types'
import { isTriggerValid } from '@/triggers'
const logger = createLogger('SubBlockStore')
/**
* Stable empty fallback for `state.workflowValues[workflowId]` selectors.
* Using a module-level constant avoids returning a fresh `{}` on every
* selector call, which would defeat Zustand's `Object.is` equality.
*/
export const EMPTY_SUBBLOCK_VALUES: Record<string, Record<string, SubBlockValue>> = {}
/**
* Stable empty fallback for a single block's sub-block values.
*/
export const EMPTY_BLOCK_SUBBLOCK_VALUES: Record<string, SubBlockValue> = {}
/**
* SubBlockState stores values for all subblocks in workflows
*
* Important implementation notes:
* 1. Values are stored per workflow, per block, per subblock
* 2. When workflows are synced to the database, the mergeSubblockState function
* in utils.ts combines the block structure with these values
* 3. If a subblock value exists here but not in the block structure
* (e.g., inputFormat in starter block), the merge function will include it
* in the synchronized state to ensure persistence
*/
export const useSubBlockStore = create<SubBlockStore>()(
devtools((set, get) => ({
workflowValues: {},
setValue: (blockId: string, subBlockId: string, value: any) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
let validatedValue = value
if (Array.isArray(value)) {
const isTableData =
value.length > 0 &&
value.some((item) => item && typeof item === 'object' && 'cells' in item)
if (isTableData) {
logger.debug('Validating table data for subblock', { blockId, subBlockId })
validatedValue = value.map((row: any) => {
if (!row || typeof row !== 'object') {
logger.warn('Fixing malformed table row', { blockId, subBlockId, row })
return {
id: generateId(),
cells: { Key: '', Value: '' },
}
}
if (!row.id) {
row.id = generateId()
}
if (!row.cells || typeof row.cells !== 'object') {
logger.warn('Fixing malformed table row cells', { blockId, subBlockId, row })
row.cells = { Key: '', Value: '' }
}
return row
})
}
}
set((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[blockId]: {
...state.workflowValues[activeWorkflowId]?.[blockId],
[subBlockId]: validatedValue,
},
},
},
}))
},
getValue: (blockId: string, subBlockId: string) => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return null
return get().workflowValues[activeWorkflowId]?.[blockId]?.[subBlockId] ?? null
},
clear: () => {
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (!activeWorkflowId) return
set((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {},
},
}))
},
initializeFromWorkflow: (workflowId: string, blocks: Record<string, any>) => {
const values: Record<string, Record<string, any>> = {}
Object.entries(blocks).forEach(([blockId, block]) => {
values[blockId] = {}
Object.entries(block.subBlocks || {}).forEach(([subBlockId, subBlock]) => {
values[blockId][subBlockId] = (subBlock as SubBlockConfig).value
})
})
set((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: values,
},
}))
Object.entries(blocks).forEach(([blockId, block]) => {
const blockConfig = getBlock(block.type)
if (!blockConfig) return
const isTriggerBlock = blockConfig.category === 'triggers' || block.triggerMode === true
if (!isTriggerBlock) return
let triggerId: string | undefined
if (blockConfig.category === 'triggers') {
triggerId = block.type
} else if (block.triggerMode === true && blockConfig.triggers?.enabled) {
const selectedTriggerIdValue = block.subBlocks?.selectedTriggerId?.value
const triggerIdValue = block.subBlocks?.triggerId?.value
triggerId =
(typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue)
? selectedTriggerIdValue
: undefined) ||
(typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue)
? triggerIdValue
: undefined) ||
blockConfig.triggers?.available?.[0]
}
if (!triggerId || !isTriggerValid(triggerId)) {
return
}
const triggerConfigSubBlock = block.subBlocks?.triggerConfig
if (triggerConfigSubBlock?.value && typeof triggerConfigSubBlock.value === 'object') {
populateTriggerFieldsFromConfig(blockId, triggerConfigSubBlock.value, triggerId)
}
})
},
setWorkflowValues: (workflowId: string, values: Record<string, Record<string, any>>) => {
set((state) => ({
workflowValues: {
...state.workflowValues,
[workflowId]: values,
},
}))
},
}))
)
@@ -0,0 +1,23 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
/**
* Value type for subblock values.
* Uses unknown to support various value types that subblocks can store,
* including strings, numbers, arrays, objects, and other complex structures.
*/
export type SubBlockValue = unknown
interface SubBlockStoreState {
workflowValues: Record<string, Record<string, Record<string, SubBlockValue>>> // Store values per workflow ID
}
export interface SubBlockStore extends SubBlockStoreState {
setValue: (blockId: string, subBlockId: string, value: SubBlockValue) => void
getValue: (blockId: string, subBlockId: string) => SubBlockValue | undefined
clear: () => void
initializeFromWorkflow: (workflowId: string, blocks: Record<string, BlockState>) => void
setWorkflowValues: (
workflowId: string,
values: Record<string, Record<string, SubBlockValue>>
) => void
}
+797
View File
@@ -0,0 +1,797 @@
import {
createAgentBlock,
createBlock,
createFunctionBlock,
createLoopBlock,
createStarterBlock,
} from '@sim/testing'
import { describe, expect, it } from 'vitest'
import { normalizeName } from '@/executor/constants'
import { getUniqueBlockName, regenerateBlockIds } from './utils'
describe('normalizeName', () => {
it.concurrent('should convert to lowercase', () => {
expect(normalizeName('MyVariable')).toBe('myvariable')
expect(normalizeName('UPPERCASE')).toBe('uppercase')
expect(normalizeName('MixedCase')).toBe('mixedcase')
})
it.concurrent('should remove spaces', () => {
expect(normalizeName('my variable')).toBe('myvariable')
expect(normalizeName('my variable')).toBe('myvariable')
expect(normalizeName(' spaced ')).toBe('spaced')
})
it.concurrent('should handle both lowercase and space removal', () => {
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
expect(normalizeName('My Block Name')).toBe('myblockname')
expect(normalizeName('API 1')).toBe('api1')
})
it.concurrent('should handle edge cases', () => {
expect(normalizeName('')).toBe('')
expect(normalizeName(' ')).toBe('')
expect(normalizeName('a')).toBe('a')
expect(normalizeName('already_normalized')).toBe('already_normalized')
})
it.concurrent('should preserve non-space special characters except dots', () => {
expect(normalizeName('my-variable')).toBe('my-variable')
expect(normalizeName('my_variable')).toBe('my_variable')
})
it.concurrent('should strip dots since they conflict with the reference path delimiter', () => {
expect(normalizeName('my.variable')).toBe('myvariable')
expect(normalizeName('Trigger.dev 1')).toBe('triggerdev1')
expect(normalizeName('Hunter.io 2')).toBe('hunterio2')
})
it.concurrent('should handle tabs and newlines as whitespace', () => {
expect(normalizeName('my\tvariable')).toBe('myvariable')
expect(normalizeName('my\nvariable')).toBe('myvariable')
expect(normalizeName('my\r\nvariable')).toBe('myvariable')
})
it.concurrent('should handle unicode characters', () => {
expect(normalizeName('Café')).toBe('café')
expect(normalizeName('日本語')).toBe('日本語')
})
it.concurrent('should normalize block names correctly', () => {
expect(normalizeName('Agent 1')).toBe('agent1')
expect(normalizeName('API Block')).toBe('apiblock')
expect(normalizeName('My Custom Block')).toBe('mycustomblock')
})
it.concurrent('should normalize variable names correctly', () => {
expect(normalizeName('jira1')).toBe('jira1')
expect(normalizeName('JIRA TEAM UUID')).toBe('jirateamuuid')
expect(normalizeName('My Variable')).toBe('myvariable')
})
it.concurrent('should produce consistent results for references', () => {
const originalName = 'JIRA TEAM UUID'
const normalized1 = normalizeName(originalName)
const normalized2 = normalizeName(originalName)
expect(normalized1).toBe(normalized2)
expect(normalized1).toBe('jirateamuuid')
})
it.concurrent('should allow matching block references to variable references', () => {
const name = 'API Block'
const blockRef = `<${normalizeName(name)}.output>`
const varRef = `<variable.${normalizeName(name)}>`
expect(blockRef).toBe('<apiblock.output>')
expect(varRef).toBe('<variable.apiblock>')
})
it.concurrent('should handle real-world naming patterns consistently', () => {
const realWorldNames = [
{ input: 'User ID', expected: 'userid' },
{ input: 'API Key', expected: 'apikey' },
{ input: 'OAuth Token', expected: 'oauthtoken' },
{ input: 'Database URL', expected: 'databaseurl' },
{ input: 'STRIPE SECRET KEY', expected: 'stripesecretkey' },
{ input: 'openai api key', expected: 'openaiapikey' },
{ input: 'Customer Name', expected: 'customername' },
{ input: 'Order Total', expected: 'ordertotal' },
]
for (const { input, expected } of realWorldNames) {
expect(normalizeName(input)).toBe(expected)
}
})
})
describe('getUniqueBlockName', () => {
it('should return "Start" for starter blocks', () => {
expect(getUniqueBlockName('Start', {})).toBe('Start')
expect(getUniqueBlockName('Starter', {})).toBe('Start')
expect(getUniqueBlockName('start', {})).toBe('Start')
})
it('should return name with number 1 when no existing blocks', () => {
expect(getUniqueBlockName('Agent', {})).toBe('Agent 1')
expect(getUniqueBlockName('Function', {})).toBe('Function 1')
expect(getUniqueBlockName('Loop', {})).toBe('Loop 1')
})
it('should increment number when existing blocks have same base name', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
})
it('should find highest number and increment', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
'block-2': createAgentBlock({ id: 'block-2', name: 'Agent 3' }),
'block-3': createAgentBlock({ id: 'block-3', name: 'Agent 2' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 4')
})
it('should handle base name with existing number suffix', () => {
const existingBlocks = {
'block-1': createFunctionBlock({ id: 'block-1', name: 'Function 1' }),
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 2' }),
}
expect(getUniqueBlockName('Function 1', existingBlocks)).toBe('Function 3')
expect(getUniqueBlockName('Function 5', existingBlocks)).toBe('Function 3')
})
it('should be case insensitive when matching base names', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'API 1' }),
'block-2': createBlock({ id: 'block-2', name: 'api 2' }),
}
expect(getUniqueBlockName('API', existingBlocks)).toBe('API 3')
expect(getUniqueBlockName('api', existingBlocks)).toBe('api 3')
})
it('should handle different block types independently', () => {
const existingBlocks = {
'block-1': createAgentBlock({ id: 'block-1', name: 'Agent 1' }),
'block-2': createFunctionBlock({ id: 'block-2', name: 'Function 1' }),
'block-3': createLoopBlock({ id: 'block-3', name: 'Loop 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 2')
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
expect(getUniqueBlockName('Loop', existingBlocks)).toBe('Loop 2')
expect(getUniqueBlockName('Router', existingBlocks)).toBe('Router 1')
})
it('should handle blocks without numbers as having number 0', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'Custom' }),
}
expect(getUniqueBlockName('Custom', existingBlocks)).toBe('Custom 1')
})
it('should handle multi-word base names', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'API Block 1' }),
'block-2': createBlock({ id: 'block-2', name: 'API Block 2' }),
}
expect(getUniqueBlockName('API Block', existingBlocks)).toBe('API Block 3')
})
it('should handle starter blocks even with existing starters', () => {
const existingBlocks = {
'block-1': createStarterBlock({ id: 'block-1', name: 'Start' }),
}
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
expect(getUniqueBlockName('Starter', existingBlocks)).toBe('Start')
})
it('should handle empty string base name', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: ' 1' }),
}
expect(getUniqueBlockName('', existingBlocks)).toBe(' 1')
})
it('should handle complex real-world scenarios', () => {
const existingBlocks = {
starter: createStarterBlock({ id: 'starter', name: 'Start' }),
agent1: createAgentBlock({ id: 'agent1', name: 'Agent 1' }),
agent2: createAgentBlock({ id: 'agent2', name: 'Agent 2' }),
func1: createFunctionBlock({ id: 'func1', name: 'Function 1' }),
loop1: createLoopBlock({ id: 'loop1', name: 'Loop 1' }),
}
expect(getUniqueBlockName('Agent', existingBlocks)).toBe('Agent 3')
expect(getUniqueBlockName('Function', existingBlocks)).toBe('Function 2')
expect(getUniqueBlockName('Start', existingBlocks)).toBe('Start')
expect(getUniqueBlockName('Condition', existingBlocks)).toBe('Condition 1')
})
it('should preserve original base name casing in result', () => {
const existingBlocks = {
'block-1': createBlock({ id: 'block-1', name: 'MyBlock 1' }),
}
expect(getUniqueBlockName('MyBlock', existingBlocks)).toBe('MyBlock 2')
expect(getUniqueBlockName('MYBLOCK', existingBlocks)).toBe('MYBLOCK 2')
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
})
})
describe('regenerateBlockIds', () => {
const positionOffset = { x: 50, y: 50 }
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBe(loopId)
expect(duplicatedBlock.data?.extent).toBe('parent')
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
})
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
const nonExistentParentId = 'non-existent-loop'
const childId = 'child-1'
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: nonExistentParentId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const duplicatedBlock = newBlocks[0]
expect(duplicatedBlock.data?.parentId).toBeUndefined()
expect(duplicatedBlock.data?.extent).toBeUndefined()
})
it('should remap parentId when copying both parent and child together', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const blocksToCopy = {
[loopId]: createLoopBlock({
id: loopId,
name: 'Loop 1',
position: { x: 200, y: 200 },
}),
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
const newLoop = newBlocks.find((b) => b.type === 'loop')
const newChild = newBlocks.find((b) => b.type === 'agent')
expect(newLoop).toBeDefined()
expect(newChild).toBeDefined()
expect(newChild!.data?.parentId).toBe(newLoop!.id)
expect(newChild!.data?.extent).toBe('parent')
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
expect(newChild!.position).toEqual({ x: 100, y: 50 })
})
it('should apply offset to top-level blocks', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
})
it('should generate unique names for duplicated blocks', () => {
const blockId = 'block-1'
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 2')
})
it('should ignore large viewport offset for blocks inside existing subflows', () => {
const loopId = 'loop-1'
const childId = 'child-1'
const existingBlocks = {
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
}
const blocksToCopy = {
[childId]: createAgentBlock({
id: childId,
name: 'Agent 1',
position: { x: 100, y: 50 },
data: { parentId: loopId, extent: 'parent' },
}),
}
const largeViewportOffset = { x: 2000, y: 1500 }
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
largeViewportOffset,
existingBlocks,
getUniqueBlockName
)
const duplicatedBlock = Object.values(result.blocks)[0]
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
expect(duplicatedBlock.data?.parentId).toBe(loopId)
})
it('should unlock pasted block when source is locked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
// Pasted blocks are always unlocked so users can edit them
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should keep pasted block unlocked when source is unlocked', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Unlocked Agent',
position: { x: 100, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
const pastedBlock = newBlocks[0]
expect(pastedBlock.locked).toBe(false)
})
it('should unlock all pasted blocks regardless of source locked state', () => {
const lockedId = 'locked-1'
const unlockedId = 'unlocked-1'
const blocksToCopy = {
[lockedId]: createAgentBlock({
id: lockedId,
name: 'Originally Locked Agent',
position: { x: 100, y: 50 },
locked: true,
}),
[unlockedId]: createFunctionBlock({
id: unlockedId,
name: 'Originally Unlocked Function',
position: { x: 200, y: 50 },
locked: false,
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(2)
for (const block of newBlocks) {
expect(block.locked).toBe(false)
}
})
it('should preserve original name when no conflicting block exists', () => {
const blockId = 'block-1'
const blocksToCopy = {
[blockId]: createAgentBlock({
id: blockId,
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
expect(newBlocks).toHaveLength(1)
expect(newBlocks[0].name).toBe('Agent 1')
})
it('should preserve original name with number suffix when no conflict', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 3',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 3')
})
it('should increment name when an exact match exists in destination', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 2')
})
it('should preserve name when only a different-numbered sibling exists', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 2' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 5',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Agent 5')
})
it('should preserve names for multiple blocks when no conflicts', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createFunctionBlock({
id: 'block-2',
name: 'Function 3',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const agentBlock = newBlocks.find((b) => b.type === 'agent')
const functionBlock = newBlocks.find((b) => b.type === 'function')
expect(agentBlock!.name).toBe('Agent 1')
expect(functionBlock!.name).toBe('Function 3')
})
it('should handle mixed conflicts: preserve non-conflicting, increment conflicting', () => {
const existingBlocks = {
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
}
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createFunctionBlock({
id: 'block-2',
name: 'Function 1',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const agentBlock = newBlocks.find((b) => b.type === 'agent')
const functionBlock = newBlocks.find((b) => b.type === 'function')
expect(agentBlock!.name).toBe('Agent 2')
expect(functionBlock!.name).toBe('Function 1')
})
it('should detect conflicts case-insensitively', () => {
const existingBlocks = {
existing: createBlock({ id: 'existing', name: 'api 1' }),
}
const blocksToCopy = {
'block-1': createBlock({
id: 'block-1',
name: 'API 1',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
existingBlocks,
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('API 2')
})
it('should preserve name without number suffix when no conflict', () => {
const blocksToCopy = {
'block-1': createBlock({
id: 'block-1',
name: 'Custom Block',
position: { x: 100, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
expect(Object.values(result.blocks)[0].name).toBe('Custom Block')
})
it('should avoid collisions between pasted blocks themselves', () => {
const blocksToCopy = {
'block-1': createAgentBlock({
id: 'block-1',
name: 'Agent 1',
position: { x: 100, y: 100 },
}),
'block-2': createAgentBlock({
id: 'block-2',
name: 'Agent 1',
position: { x: 200, y: 100 },
}),
}
const result = regenerateBlockIds(
blocksToCopy,
[],
{},
{},
{},
positionOffset,
{},
getUniqueBlockName
)
const newBlocks = Object.values(result.blocks)
const names = newBlocks.map((b) => b.name)
expect(names).toHaveLength(2)
expect(new Set(names).size).toBe(2)
expect(names).toContain('Agent 1')
expect(names).toContain('Agent 2')
})
})
+610
View File
@@ -0,0 +1,610 @@
import { generateId } from '@sim/utils/id'
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
import { createDefaultInputFormatField } from '@/lib/workflows/input-format'
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getBlock } from '@/blocks'
import { normalizeName } from '@/executor/constants'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type {
BlockState,
Loop,
Parallel,
Position,
SubBlockState,
WorkflowState,
} from '@/stores/workflows/workflow/types'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
/** Threshold to detect viewport-based offsets vs small duplicate offsets */
const LARGE_OFFSET_THRESHOLD = 300
/**
* Filters edges to only include valid ones (target exists and is not a trigger block)
*/
export function filterValidEdges(edges: Edge[], blocks: Record<string, BlockState>): Edge[] {
return validateEdges(edges, blocks).valid
}
export function filterNewEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return filterUniqueWorkflowEdges(edgesToAdd, currentEdges)
}
export interface RegeneratedState {
blocks: Record<string, BlockState>
edges: Edge[]
loops: Record<string, Loop>
parallels: Record<string, Parallel>
idMap: Map<string, string>
}
/**
* Generates a unique block name by finding the highest number suffix among existing blocks
* with the same base name and incrementing it
* @param baseName - The base name for the block (e.g., "API 1", "Agent", "Loop 3")
* @param existingBlocks - Record of existing blocks to check against
* @returns A unique block name with an appropriate number suffix
*/
export function getUniqueBlockName(baseName: string, existingBlocks: Record<string, any>): string {
// Special case: Start blocks should always be named "Start" without numbers
// This applies to both "Start" and "Starter" base names
const normalizedBaseName = normalizeName(baseName)
if (normalizedBaseName === 'start' || normalizedBaseName === 'starter') {
return 'Start'
}
if (normalizedBaseName === 'response') {
return 'Response'
}
const baseNameMatch = baseName.match(/^(.*?)(\s+\d+)?$/)
const namePrefix = baseNameMatch ? baseNameMatch[1].trim() : baseName
const normalizedBase = normalizeName(namePrefix)
const existingNumbers = Object.values(existingBlocks)
.filter((block) => {
const blockNameMatch = block.name?.match(/^(.*?)(\s+\d+)?$/)
const blockPrefix = blockNameMatch ? blockNameMatch[1].trim() : block.name
return blockPrefix && normalizeName(blockPrefix) === normalizedBase
})
.map((block) => {
const match = block.name?.match(/(\d+)$/)
return match ? Number.parseInt(match[1], 10) : 0
})
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0
if (maxNumber === 0 && existingNumbers.length === 0) {
return `${namePrefix} 1`
}
return `${namePrefix} ${maxNumber + 1}`
}
export interface PrepareBlockStateOptions {
id: string
type: string
name: string
position: Position
data?: Record<string, unknown>
parentId?: string
extent?: 'parent'
triggerMode?: boolean
}
/**
* Prepares a BlockState object from block type and configuration.
* Generates subBlocks and outputs from the block registry.
*/
export function prepareBlockState(options: PrepareBlockStateOptions): BlockState {
const { id, type, name, position, data, parentId, extent, triggerMode = false } = options
const blockConfig = getBlock(type)
const blockData: Record<string, unknown> = { ...(data || {}) }
if (parentId) blockData.parentId = parentId
if (extent) blockData.extent = extent
if (!blockConfig) {
return {
id,
type,
name,
position,
data: blockData,
subBlocks: {},
outputs: {},
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
}
}
const subBlocks: Record<string, SubBlockState> = {}
if (blockConfig.subBlocks) {
blockConfig.subBlocks.forEach((subBlock) => {
let initialValue: unknown = null
if (typeof subBlock.value === 'function') {
try {
initialValue = subBlock.value({})
} catch {
initialValue = null
}
} else if (subBlock.defaultValue !== undefined) {
initialValue = subBlock.defaultValue
} else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') {
initialValue = [createDefaultInputFormatField()]
} else if (subBlock.type === 'table') {
initialValue = []
}
subBlocks[subBlock.id] = {
id: subBlock.id,
type: subBlock.type,
value: initialValue as SubBlockState['value'],
}
})
}
const isTriggerCapable = hasTriggerCapability(blockConfig)
const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable)
const outputs = getEffectiveBlockOutputs(type, subBlocks, {
triggerMode: effectiveTriggerMode,
preferToolOutputs: !effectiveTriggerMode,
})
if (blockConfig.subBlocks) {
const canonicalModes = buildDefaultCanonicalModes(blockConfig.subBlocks)
if (Object.keys(canonicalModes).length > 0) {
blockData.canonicalModes = canonicalModes
}
}
return {
id,
type,
name,
position,
data: blockData,
subBlocks,
outputs,
enabled: true,
horizontalHandles: true,
advancedMode: false,
triggerMode,
height: 0,
locked: false,
}
}
/**
* Merges workflow block states with subblock values while maintaining block structure
* @param blocks - Block configurations from workflow store
* @param workflowId - ID of the workflow to merge values for
* @param blockId - Optional specific block ID to merge (merges all if not provided)
* @returns Merged block states with updated values
*/
export function mergeSubblockState(
blocks: Record<string, BlockState>,
workflowId?: string,
blockId?: string
): Record<string, BlockState> {
const subBlockStore = useSubBlockStore.getState()
const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
if (workflowId) {
return mergeSubblockStateWithValues(blocks, workflowSubblockValues, blockId)
}
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
if (!block) {
return acc
}
const blockSubBlocks = block.subBlocks || {}
const blockValues = workflowSubblockValues[id] || {}
const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
if (!subBlock) {
return subAcc
}
let storedValue = null
if (workflowId) {
if (blockValues[subBlockId] !== undefined) {
storedValue = blockValues[subBlockId]
}
} else {
storedValue = subBlockStore.getValue(id, subBlockId)
}
subAcc[subBlockId] = {
...subBlock,
value: (storedValue !== undefined && storedValue !== null
? storedValue
: subBlock.value) as SubBlockState['value'],
}
return subAcc
},
{} as Record<string, SubBlockState>
)
// Add any values that exist in the store but aren't in the block structure
// This handles cases where block config has been updated but values still exist
// IMPORTANT: This includes runtime subblock IDs like webhookId, triggerPath, etc.
Object.entries(blockValues).forEach(([subBlockId, value]) => {
if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
// Create a minimal subblock structure
mergedSubBlocks[subBlockId] = {
id: subBlockId,
type: 'short-input', // Default type that's safe to use
value: value as SubBlockState['value'],
}
}
})
// Return the full block state with updated subBlocks (including orphaned values)
acc[id] = {
...block,
subBlocks: mergedSubBlocks,
}
return acc
},
{} as Record<string, BlockState>
)
}
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
if (typeof value === 'string') {
let updatedValue = value
nameMap.forEach((newName, oldName) => {
const regex = new RegExp(`<${oldName}\\.`, 'g')
updatedValue = updatedValue.replace(regex, `<${newName}.`)
})
return updatedValue
}
if (Array.isArray(value)) {
return value.map((item) => updateValueReferences(item, nameMap))
}
if (value && typeof value === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(value)) {
result[key] = updateValueReferences(val, nameMap)
}
return result
}
return value
}
function updateBlockReferences(
blocks: Record<string, BlockState>,
nameMap: Map<string, string>,
clearTriggerRuntimeValues = false
): void {
Object.entries(blocks).forEach(([_, block]) => {
if (block.subBlocks) {
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
block.subBlocks[subBlockId] = { ...subBlock, value: null }
return
}
if (subBlock.value !== undefined && subBlock.value !== null) {
const updatedValue = updateValueReferences(
subBlock.value,
nameMap
) as SubBlockState['value']
block.subBlocks[subBlockId] = { ...subBlock, value: updatedValue }
}
})
}
})
}
export function regenerateWorkflowIds(
workflowState: WorkflowState,
options: { clearTriggerRuntimeValues?: boolean } = {}
): WorkflowState & { idMap: Map<string, string> } {
const { clearTriggerRuntimeValues = true } = options
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
// First pass: generate new IDs and remap condition/router IDs in subBlocks
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
const newId = generateId()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
nameMap.set(oldNormalizedName, oldNormalizedName)
const newBlock = { ...block, id: newId, subBlocks: structuredClone(block.subBlocks) }
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
newBlocks[newId] = newBlock
})
// Second pass: update parentId references
Object.values(newBlocks).forEach((block) => {
if (block.data?.parentId) {
const newParentId = blockIdMap.get(block.data.parentId)
if (newParentId) {
block.data = { ...block.data, parentId: newParentId }
} else {
// Parent not in the workflow, clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
})
const newEdges = workflowState.edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: generateId(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
if (workflowState.loops) {
Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
const newParallels: Record<string, Parallel> = {}
if (workflowState.parallels) {
Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
}
updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
metadata: workflowState.metadata,
variables: workflowState.variables,
idMap: blockIdMap,
}
}
/**
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
*/
export function remapConditionIds(
subBlocks: Record<string, SubBlockState>,
subBlockValues: Record<string, unknown>,
oldBlockId: string,
newBlockId: string
): void {
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
const value = subBlockValues[subBlockId] ?? subBlock.value
if (typeof value !== 'string') continue
try {
const parsed = JSON.parse(value)
if (!Array.isArray(parsed)) continue
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
const newValue = JSON.stringify(parsed)
subBlock.value = newValue
subBlockValues[subBlockId] = newValue
}
} catch {
// Not valid JSON, skip
}
}
}
export function regenerateBlockIds(
blocks: Record<string, BlockState>,
edges: Edge[],
loops: Record<string, Loop>,
parallels: Record<string, Parallel>,
subBlockValues: Record<string, Record<string, unknown>>,
positionOffset: { x: number; y: number },
existingBlockNames: Record<string, BlockState>,
uniqueNameFn: (name: string, blocks: Record<string, BlockState>) => string
): RegeneratedState & { subBlockValues: Record<string, Record<string, unknown>> } {
const blockIdMap = new Map<string, string>()
const nameMap = new Map<string, string>()
const newBlocks: Record<string, BlockState> = {}
const newSubBlockValues: Record<string, Record<string, unknown>> = {}
// Track all blocks for name uniqueness (existing + newly processed)
const allBlocksForNaming = { ...existingBlockNames }
// First pass: generate new IDs and names for all blocks
Object.entries(blocks).forEach(([oldId, block]) => {
const newId = generateId()
blockIdMap.set(oldId, newId)
const oldNormalizedName = normalizeName(block.name)
const nameConflicts = Object.values(allBlocksForNaming).some(
(existing) => normalizeName(existing.name) === oldNormalizedName
)
const newName = nameConflicts ? uniqueNameFn(block.name, allBlocksForNaming) : block.name
const newNormalizedName = normalizeName(newName)
nameMap.set(oldNormalizedName, newNormalizedName)
// Determine position offset based on parent relationship:
// 1. Parent also being copied: keep exact relative position (parent itself will be offset)
// 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
// offsets since they don't make sense for relative positions
// 3. Top-level block (no parent): apply full paste offset
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
const hasParentInExistingWorkflow =
block.data?.parentId && existingBlockNames[block.data.parentId]
let newPosition: Position
if (hasParentInPasteSet) {
// Parent also being copied - keep exact relative position
newPosition = { x: block.position.x, y: block.position.y }
} else if (hasParentInExistingWorkflow) {
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
const isLargeOffset =
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
newPosition = {
x: block.position.x + effectiveOffset.x,
y: block.position.y + effectiveOffset.y,
}
} else {
// Top-level block - apply full paste offset
newPosition = {
x: block.position.x + positionOffset.x,
y: block.position.y + positionOffset.y,
}
}
// Placeholder block - we'll update parentId in second pass
const newBlock: BlockState = {
...block,
id: newId,
name: newName,
position: newPosition,
subBlocks: structuredClone(block.subBlocks),
// Temporarily keep data as-is, we'll fix parentId in second pass
data: block.data ? { ...block.data } : block.data,
// Duplicated blocks are always unlocked so users can edit them
locked: false,
}
newBlocks[newId] = newBlock
// Add to tracking so next block gets unique name
allBlocksForNaming[newId] = newBlock
if (subBlockValues[oldId]) {
newSubBlockValues[newId] = structuredClone(subBlockValues[oldId])
}
// Remap condition/router IDs in the duplicated block
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
})
// Second pass: update parentId references for nested blocks
// If a block's parent is also being pasted, map to new parentId
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
// Otherwise clear the parentId
Object.entries(newBlocks).forEach(([, block]) => {
if (block.data?.parentId) {
const oldParentId = block.data.parentId
const newParentId = blockIdMap.get(oldParentId)
if (newParentId) {
// Parent is being pasted - map to new parent ID
block.data = {
...block.data,
parentId: newParentId,
extent: 'parent',
}
} else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) {
// Parent exists in existing workflow and is not locked - keep original parentId
block.data = {
...block.data,
parentId: oldParentId,
extent: 'parent',
}
} else {
// Parent doesn't exist anywhere OR parent is locked - clear the relationship
block.data = { ...block.data, parentId: undefined, extent: undefined }
}
}
})
const newEdges = edges.map((edge) => {
const newSource = blockIdMap.get(edge.source) || edge.source
const newSourceHandle =
edge.sourceHandle && blockIdMap.has(edge.source)
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
: edge.sourceHandle
return {
...edge,
id: generateId(),
source: newSource,
target: blockIdMap.get(edge.target) || edge.target,
sourceHandle: newSourceHandle,
}
})
const newLoops: Record<string, Loop> = {}
Object.entries(loops).forEach(([oldLoopId, loop]) => {
const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId
newLoops[newLoopId] = {
...loop,
id: newLoopId,
nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
const newParallels: Record<string, Parallel> = {}
Object.entries(parallels).forEach(([oldParallelId, parallel]) => {
const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId
newParallels[newParallelId] = {
...parallel,
id: newParallelId,
nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId),
}
})
updateBlockReferences(newBlocks, nameMap, false)
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
Object.keys(blockValues).forEach((subBlockId) => {
blockValues[subBlockId] = updateValueReferences(blockValues[subBlockId], nameMap)
})
})
return {
blocks: newBlocks,
edges: newEdges,
loops: newLoops,
parallels: newParallels,
subBlockValues: newSubBlockValues,
idMap: blockIdMap,
}
}
@@ -0,0 +1,99 @@
import type { Edge } from 'reactflow'
import { describe, expect, it } from 'vitest'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { BlockState } from '@/stores/workflows/workflow/types'
function makeBlock(id: string, type: string, overrides?: Partial<BlockState>): BlockState {
return {
id,
type,
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
}
}
function makeEdge(id: string, source: string, target: string): Edge {
return { id, source, target, type: 'default' }
}
describe('validateEdges', () => {
it('accepts an edge between two root-scope blocks', () => {
const blocks = {
a: makeBlock('a', 'starter'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
expect(result.dropped).toHaveLength(0)
})
it('drops an edge referencing a missing block', () => {
const blocks = { a: makeBlock('a', 'starter') }
const result = validateEdges([makeEdge('e1', 'a', 'missing')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references a missing block')
})
it('drops an edge touching an annotation-only (note) block', () => {
const blocks = {
a: makeBlock('a', 'note'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references an annotation-only block')
})
it('drops an edge targeting a trigger block', () => {
const blocks = {
a: makeBlock('a', 'function'),
b: makeBlock('b', 'function', { triggerMode: true }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('trigger blocks cannot be edge targets')
})
it('drops an edge crossing loop scope boundaries', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
outer: makeBlock('outer', 'function'),
}
const result = validateEdges([makeEdge('e1', 'inner', 'outer')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toContain('different scopes')
})
it('accepts an edge from a loop container into its own child', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'loop', 'inner')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts an edge from a loop child back out to its own container', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'inner', 'loop')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts edges between two siblings inside the same loop', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
a: makeBlock('a', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
b: makeBlock('b', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
})
})
@@ -0,0 +1,58 @@
import {
getWorkflowEdgeScopeDropReason,
isWorkflowAnnotationOnlyBlockType,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import type { BlockState } from '@/stores/workflows/workflow/types'
interface DroppedEdge {
edge: Edge
reason: string
}
export interface EdgeValidationResult {
valid: Edge[]
dropped: DroppedEdge[]
}
export function validateEdges(
edges: Edge[],
blocks: Record<string, BlockState>
): EdgeValidationResult {
const valid: Edge[] = []
const dropped: DroppedEdge[] = []
for (const edge of edges) {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) {
dropped.push({ edge, reason: 'edge references a missing block' })
continue
}
if (
isWorkflowAnnotationOnlyBlockType(sourceBlock.type) ||
isWorkflowAnnotationOnlyBlockType(targetBlock.type)
) {
dropped.push({ edge, reason: 'edge references an annotation-only block' })
continue
}
if (TriggerUtils.isTriggerBlock(targetBlock)) {
dropped.push({ edge, reason: 'trigger blocks cannot be edge targets' })
continue
}
const scopeDropReason = getWorkflowEdgeScopeDropReason(edge, blocks)
if (scopeDropReason) {
dropped.push({ edge, reason: scopeDropReason })
continue
}
valid.push(edge)
}
return { valid, dropped }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
export type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
}
export { isValidSubflowType, SUBFLOW_TYPES } from '@sim/workflow-types/workflow'
export interface WorkflowActions {
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
batchUpdateBlocksWithParent: (
updates: Array<{
id: string
position: { x: number; y: number }
parentId?: string
}>
) => void
batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => void
batchAddBlocks: (
blocks: BlockState[],
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
) => void
batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void
batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: (
id: string,
name: string
) => {
success: boolean
changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }>
}
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
/**
* Wholesale-replaces `block.data.canonicalModes`, rather than merging one key like
* {@link setBlockCanonicalMode}. Needed when reindexing nested tool-input overrides on
* reorder/removal: a merge can't atomically drop a now-stale index key, and sequential
* per-key sets can clobber each other when two tools swap positions.
*/
setBlockCanonicalModes: (id: string, canonicalModes: Record<string, 'basic' | 'advanced'>) => void
syncDynamicHandleSubblockValue: (blockId: string, subblockId: string, value: unknown) => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void
updateParallelBatchSize: (parallelId: string, batchSize: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState
replaceWorkflowState: (
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
setCurrentWorkflowId: (workflowId: string | null) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions
@@ -0,0 +1,146 @@
import { createAgentBlock, createLoopBlock } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
import {
convertLoopBlockToLoop,
isAncestorProtected,
isBlockProtected,
} from '@/stores/workflows/workflow/utils'
describe('convertLoopBlockToLoop', () => {
it.concurrent('should keep JSON array string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 10,
data: { collection: '["item1", "item2", "item3"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('["item1", "item2", "item3"]')
expect(result?.iterations).toBe(10)
})
it.concurrent('should keep JSON object string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '{"key1": "value1", "key2": "value2"}' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('{"key1": "value1", "key2": "value2"}')
})
it.concurrent('should keep string as-is if not valid JSON', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '<blockName.items>' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('<blockName.items>')
})
it.concurrent('should handle empty collection', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('')
})
it.concurrent('should handle for loops without collection parsing', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'for',
count: 5,
data: { collection: '["should", "not", "matter"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('for')
expect(result?.iterations).toBe(5)
expect(result?.forEachItems).toBe('["should", "not", "matter"]')
})
})
describe('block lock protection', () => {
it.concurrent('treats deeply nested blocks inside locked containers as protected', () => {
const blocks: Record<string, BlockState> = {
grandparent: createLoopBlock({
id: 'grandparent',
name: 'Grandparent Loop',
locked: true,
}),
parent: createLoopBlock({
id: 'parent',
name: 'Parent Loop',
parentId: 'grandparent',
}),
child: createAgentBlock({
id: 'child',
name: 'Child Agent',
parentId: 'parent',
}),
}
expect(isAncestorProtected('child', blocks)).toBe(true)
expect(isBlockProtected('child', blocks)).toBe(true)
})
it.concurrent(
'does not treat ancestor cycles as protected unless a locked ancestor is found',
() => {
const blocks: Record<string, BlockState> = {
first: createAgentBlock({
id: 'first',
name: 'First Agent',
parentId: 'second',
}),
second: createAgentBlock({
id: 'second',
name: 'Second Agent',
parentId: 'first',
}),
}
expect(isAncestorProtected('first', blocks)).toBe(false)
expect(isBlockProtected('first', blocks)).toBe(false)
}
)
})
+216
View File
@@ -0,0 +1,216 @@
import {
filterAcyclicEdges as filterAcyclicWorkflowEdges,
isWorkflowBlockAncestorLocked,
isWorkflowBlockProtected,
wouldCreateCycle as wouldCreateWorkflowEdgeCycle,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_BATCH_SIZE = 20
const MAX_PARALLEL_BATCH_SIZE = 20
export function clampParallelBatchSize(batchSize: unknown): number {
const parsed = typeof batchSize === 'number' ? batchSize : Number.parseInt(String(batchSize), 10)
if (Number.isNaN(parsed)) {
return DEFAULT_PARALLEL_BATCH_SIZE
}
return Math.max(1, Math.min(MAX_PARALLEL_BATCH_SIZE, parsed))
}
/**
* Check if adding an edge would create a cycle in the graph.
* Delegates to the shared implementation in `@sim/workflow-types` so the
* client store, the collaborative queueing layer, and the realtime
* persistence layer all agree on the same cyclic edges.
*/
export function wouldCreateCycle(edges: Edge[], sourceId: string, targetId: string): boolean {
return wouldCreateWorkflowEdgeCycle(edges, sourceId, targetId)
}
/**
* Filters a batch of candidate edges down to the ones that do not create a
* cycle against `currentEdges`, evaluated incrementally within the batch.
*/
export function filterAcyclicEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return filterAcyclicWorkflowEdges(edgesToAdd, currentEdges)
}
/**
* Convert UI loop block to executor Loop format
*
* @param loopBlockId - ID of the loop block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Loop object for execution engine or undefined if not a valid loop
*/
export function convertLoopBlockToLoop(
loopBlockId: string,
blocks: Record<string, BlockState>
): Loop | undefined {
const loopBlock = blocks[loopBlockId]
if (!loopBlock || loopBlock.type !== 'loop') return undefined
const loopType = loopBlock.data?.loopType || 'for'
const loop: Loop = {
id: loopBlockId,
nodes: findChildNodes(loopBlockId, blocks),
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType,
enabled: loopBlock.enabled,
}
loop.forEachItems = loopBlock.data?.collection || ''
loop.whileCondition = loopBlock.data?.whileCondition || ''
loop.doWhileCondition = loopBlock.data?.doWhileCondition || ''
return loop
}
/**
* Convert UI parallel block to executor Parallel format
*
* @param parallelBlockId - ID of the parallel block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Parallel object for execution engine or undefined if not a valid parallel block
*/
export function convertParallelBlockToParallel(
parallelBlockId: string,
blocks: Record<string, BlockState>
): Parallel | undefined {
const parallelBlock = blocks[parallelBlockId]
if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined
const parallelType = parallelBlock.data?.parallelType || 'count'
const validParallelTypes = ['collection', 'count'] as const
const validatedParallelType = validParallelTypes.includes(parallelType as any)
? parallelType
: 'collection'
const distribution =
validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : undefined
const count = parallelBlock.data?.count || 5
const batchSize = clampParallelBatchSize(parallelBlock.data?.batchSize)
return {
id: parallelBlockId,
nodes: findChildNodes(parallelBlockId, blocks),
distribution,
count,
parallelType: validatedParallelType,
batchSize,
enabled: parallelBlock.enabled,
}
}
/**
* Find all nodes that are children of this container (loop or parallel)
*
* @param containerId - ID of the container to find children for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are direct children of this container
*/
export function findChildNodes(containerId: string, blocks: Record<string, BlockState>): string[] {
return Object.values(blocks)
.filter((block) => block.data?.parentId === containerId)
.map((block) => block.id)
}
/**
* Find all descendant nodes, including children, grandchildren, etc.
*
* @param containerId - ID of the container to find descendants for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are descendants of this container
*/
export function findAllDescendantNodes(
containerId: string,
blocks: Record<string, BlockState>
): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const block of Object.values(blocks)) {
if (block.data?.parentId === current) {
descendants.push(block.id)
stack.push(block.id)
}
}
}
return descendants
}
/**
* Checks if any ancestor container of a block is locked.
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any ancestor is locked
*/
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockAncestorLocked(blockId, blocks)
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if any ancestor container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockProtected(blockId, blocks)
}
/**
* Builds a complete collection of loops from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Loop objects for execution engine
*/
export function generateLoopBlocks(blocks: Record<string, BlockState>): Record<string, Loop> {
const loops: Record<string, Loop> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'loop')
.forEach(([id, block]) => {
const loop = convertLoopBlockToLoop(id, blocks)
if (loop) {
loops[id] = loop
}
})
return loops
}
/**
* Builds a complete collection of parallel blocks from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Parallel objects for execution engine
*/
export function generateParallelBlocks(
blocks: Record<string, BlockState>
): Record<string, Parallel> {
const parallels: Record<string, Parallel> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'parallel')
.forEach(([id, block]) => {
const parallel = convertParallelBlockToParallel(id, blocks)
if (parallel) {
parallels[id] = parallel
}
})
return parallels
}
@@ -0,0 +1,67 @@
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
export interface NormalizationResult {
state: WorkflowState
warnings: string[]
}
function isContainerType(type: string | undefined): boolean {
return type === 'loop' || type === 'parallel'
}
export function normalizeWorkflowState(workflowState: WorkflowState): NormalizationResult {
const warnings: string[] = []
const blocks = structuredClone(workflowState.blocks || {})
for (const [blockId, block] of Object.entries(blocks)) {
if (!block?.type || !block?.name) {
warnings.push(`Dropped invalid block "${blockId}" because it is missing type or name`)
delete blocks[blockId]
}
}
for (const [blockId, block] of Object.entries(blocks)) {
const parentId = block.data?.parentId
if (!parentId) {
continue
}
const parentBlock = blocks[parentId]
const parentIsValidContainer = Boolean(parentBlock && isContainerType(parentBlock.type))
if (!parentIsValidContainer || parentId === blockId) {
warnings.push(`Cleared invalid parentId for block "${blockId}"`)
block.data = {
...(block.data || {}),
parentId: undefined,
extent: undefined,
}
continue
}
if (block.data?.extent !== 'parent') {
block.data = {
...(block.data || {}),
extent: 'parent',
}
}
}
const edgeValidation = validateEdges(workflowState.edges || [], blocks)
warnings.push(
...edgeValidation.dropped.map(({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`)
)
return {
state: {
...workflowState,
blocks,
edges: edgeValidation.valid,
loops: generateLoopBlocks(blocks),
parallels: generateParallelBlocks(blocks),
},
warnings,
}
}