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
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user