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,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