chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,99 @@
import type { Edge } from 'reactflow'
import { describe, expect, it } from 'vitest'
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { BlockState } from '@/stores/workflows/workflow/types'
function makeBlock(id: string, type: string, overrides?: Partial<BlockState>): BlockState {
return {
id,
type,
name: id,
position: { x: 0, y: 0 },
subBlocks: {},
outputs: {},
enabled: true,
...overrides,
}
}
function makeEdge(id: string, source: string, target: string): Edge {
return { id, source, target, type: 'default' }
}
describe('validateEdges', () => {
it('accepts an edge between two root-scope blocks', () => {
const blocks = {
a: makeBlock('a', 'starter'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
expect(result.dropped).toHaveLength(0)
})
it('drops an edge referencing a missing block', () => {
const blocks = { a: makeBlock('a', 'starter') }
const result = validateEdges([makeEdge('e1', 'a', 'missing')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references a missing block')
})
it('drops an edge touching an annotation-only (note) block', () => {
const blocks = {
a: makeBlock('a', 'note'),
b: makeBlock('b', 'function'),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('edge references an annotation-only block')
})
it('drops an edge targeting a trigger block', () => {
const blocks = {
a: makeBlock('a', 'function'),
b: makeBlock('b', 'function', { triggerMode: true }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toBe('trigger blocks cannot be edge targets')
})
it('drops an edge crossing loop scope boundaries', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
outer: makeBlock('outer', 'function'),
}
const result = validateEdges([makeEdge('e1', 'inner', 'outer')], blocks)
expect(result.valid).toHaveLength(0)
expect(result.dropped[0].reason).toContain('different scopes')
})
it('accepts an edge from a loop container into its own child', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'loop', 'inner')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts an edge from a loop child back out to its own container', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
inner: makeBlock('inner', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'inner', 'loop')], blocks)
expect(result.valid).toHaveLength(1)
})
it('accepts edges between two siblings inside the same loop', () => {
const blocks = {
loop: makeBlock('loop', 'loop'),
a: makeBlock('a', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
b: makeBlock('b', 'function', { data: { parentId: 'loop', extent: 'parent' } }),
}
const result = validateEdges([makeEdge('e1', 'a', 'b')], blocks)
expect(result.valid).toHaveLength(1)
})
})
@@ -0,0 +1,58 @@
import {
getWorkflowEdgeScopeDropReason,
isWorkflowAnnotationOnlyBlockType,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import type { BlockState } from '@/stores/workflows/workflow/types'
interface DroppedEdge {
edge: Edge
reason: string
}
export interface EdgeValidationResult {
valid: Edge[]
dropped: DroppedEdge[]
}
export function validateEdges(
edges: Edge[],
blocks: Record<string, BlockState>
): EdgeValidationResult {
const valid: Edge[] = []
const dropped: DroppedEdge[] = []
for (const edge of edges) {
const sourceBlock = blocks[edge.source]
const targetBlock = blocks[edge.target]
if (!sourceBlock || !targetBlock) {
dropped.push({ edge, reason: 'edge references a missing block' })
continue
}
if (
isWorkflowAnnotationOnlyBlockType(sourceBlock.type) ||
isWorkflowAnnotationOnlyBlockType(targetBlock.type)
) {
dropped.push({ edge, reason: 'edge references an annotation-only block' })
continue
}
if (TriggerUtils.isTriggerBlock(targetBlock)) {
dropped.push({ edge, reason: 'trigger blocks cannot be edge targets' })
continue
}
const scopeDropReason = getWorkflowEdgeScopeDropReason(edge, blocks)
if (scopeDropReason) {
dropped.push({ edge, reason: scopeDropReason })
continue
}
valid.push(edge)
}
return { valid, dropped }
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+112
View File
@@ -0,0 +1,112 @@
import type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
export type {
BlockData,
BlockLayoutState,
BlockState,
DragStartPosition,
Loop,
LoopBlock,
LoopConfig,
Parallel,
ParallelBlock,
ParallelConfig,
Position,
SubBlockState,
Subflow,
SubflowType,
Variable,
WorkflowState,
}
export { isValidSubflowType, SUBFLOW_TYPES } from '@sim/workflow-types/workflow'
export interface WorkflowActions {
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
batchUpdateBlocksWithParent: (
updates: Array<{
id: string
position: { x: number; y: number }
parentId?: string
}>
) => void
batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => void
batchAddBlocks: (
blocks: BlockState[],
edges?: Edge[],
subBlockValues?: Record<string, Record<string, unknown>>,
options?: { skipEdgeValidation?: boolean }
) => void
batchRemoveBlocks: (ids: string[]) => void
batchToggleEnabled: (ids: string[]) => void
batchToggleHandles: (ids: string[]) => void
batchAddEdges: (edges: Edge[], options?: { skipValidation?: boolean }) => void
batchRemoveEdges: (ids: string[]) => void
clear: () => Partial<WorkflowState>
updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: (
id: string,
name: string
) => {
success: boolean
changedSubblocks: Array<{ blockId: string; subBlockId: string; newValue: any }>
}
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
/**
* Wholesale-replaces `block.data.canonicalModes`, rather than merging one key like
* {@link setBlockCanonicalMode}. Needed when reindexing nested tool-input overrides on
* reorder/removal: a merge can't atomically drop a now-stale index key, and sequential
* per-key sets can clobber each other when two tools swap positions.
*/
setBlockCanonicalModes: (id: string, canonicalModes: Record<string, 'basic' | 'advanced'>) => void
syncDynamicHandleSubblockValue: (blockId: string, subblockId: string, value: unknown) => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void
updateParallelBatchSize: (parallelId: string, batchSize: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState
replaceWorkflowState: (
workflowState: WorkflowState,
options?: { updateLastSaved?: boolean }
) => void
setBlockLocked: (id: string, locked: boolean) => void
batchToggleLocked: (ids: string[]) => void
setCurrentWorkflowId: (workflowId: string | null) => void
}
export type WorkflowStore = WorkflowState & WorkflowActions
@@ -0,0 +1,146 @@
import { createAgentBlock, createLoopBlock } from '@sim/testing'
import { describe, expect, it } from 'vitest'
import type { BlockState } from '@/stores/workflows/workflow/types'
import {
convertLoopBlockToLoop,
isAncestorProtected,
isBlockProtected,
} from '@/stores/workflows/workflow/utils'
describe('convertLoopBlockToLoop', () => {
it.concurrent('should keep JSON array string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 10,
data: { collection: '["item1", "item2", "item3"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('["item1", "item2", "item3"]')
expect(result?.iterations).toBe(10)
})
it.concurrent('should keep JSON object string as-is for forEach loops', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '{"key1": "value1", "key2": "value2"}' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('forEach')
expect(result?.forEachItems).toBe('{"key1": "value1", "key2": "value2"}')
})
it.concurrent('should keep string as-is if not valid JSON', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '<blockName.items>' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('<blockName.items>')
})
it.concurrent('should handle empty collection', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'forEach',
count: 5,
data: { collection: '' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.forEachItems).toBe('')
})
it.concurrent('should handle for loops without collection parsing', () => {
const blocks: Record<string, BlockState> = {
loop1: createLoopBlock({
id: 'loop1',
name: 'Test Loop',
loopType: 'for',
count: 5,
data: { collection: '["should", "not", "matter"]' },
}),
}
const result = convertLoopBlockToLoop('loop1', blocks)
expect(result).toBeDefined()
expect(result?.loopType).toBe('for')
expect(result?.iterations).toBe(5)
expect(result?.forEachItems).toBe('["should", "not", "matter"]')
})
})
describe('block lock protection', () => {
it.concurrent('treats deeply nested blocks inside locked containers as protected', () => {
const blocks: Record<string, BlockState> = {
grandparent: createLoopBlock({
id: 'grandparent',
name: 'Grandparent Loop',
locked: true,
}),
parent: createLoopBlock({
id: 'parent',
name: 'Parent Loop',
parentId: 'grandparent',
}),
child: createAgentBlock({
id: 'child',
name: 'Child Agent',
parentId: 'parent',
}),
}
expect(isAncestorProtected('child', blocks)).toBe(true)
expect(isBlockProtected('child', blocks)).toBe(true)
})
it.concurrent(
'does not treat ancestor cycles as protected unless a locked ancestor is found',
() => {
const blocks: Record<string, BlockState> = {
first: createAgentBlock({
id: 'first',
name: 'First Agent',
parentId: 'second',
}),
second: createAgentBlock({
id: 'second',
name: 'Second Agent',
parentId: 'first',
}),
}
expect(isAncestorProtected('first', blocks)).toBe(false)
expect(isBlockProtected('first', blocks)).toBe(false)
}
)
})
+216
View File
@@ -0,0 +1,216 @@
import {
filterAcyclicEdges as filterAcyclicWorkflowEdges,
isWorkflowBlockAncestorLocked,
isWorkflowBlockProtected,
wouldCreateCycle as wouldCreateWorkflowEdgeCycle,
} from '@sim/workflow-types/workflow'
import type { Edge } from 'reactflow'
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_BATCH_SIZE = 20
const MAX_PARALLEL_BATCH_SIZE = 20
export function clampParallelBatchSize(batchSize: unknown): number {
const parsed = typeof batchSize === 'number' ? batchSize : Number.parseInt(String(batchSize), 10)
if (Number.isNaN(parsed)) {
return DEFAULT_PARALLEL_BATCH_SIZE
}
return Math.max(1, Math.min(MAX_PARALLEL_BATCH_SIZE, parsed))
}
/**
* Check if adding an edge would create a cycle in the graph.
* Delegates to the shared implementation in `@sim/workflow-types` so the
* client store, the collaborative queueing layer, and the realtime
* persistence layer all agree on the same cyclic edges.
*/
export function wouldCreateCycle(edges: Edge[], sourceId: string, targetId: string): boolean {
return wouldCreateWorkflowEdgeCycle(edges, sourceId, targetId)
}
/**
* Filters a batch of candidate edges down to the ones that do not create a
* cycle against `currentEdges`, evaluated incrementally within the batch.
*/
export function filterAcyclicEdges(edgesToAdd: Edge[], currentEdges: Edge[]): Edge[] {
return filterAcyclicWorkflowEdges(edgesToAdd, currentEdges)
}
/**
* Convert UI loop block to executor Loop format
*
* @param loopBlockId - ID of the loop block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Loop object for execution engine or undefined if not a valid loop
*/
export function convertLoopBlockToLoop(
loopBlockId: string,
blocks: Record<string, BlockState>
): Loop | undefined {
const loopBlock = blocks[loopBlockId]
if (!loopBlock || loopBlock.type !== 'loop') return undefined
const loopType = loopBlock.data?.loopType || 'for'
const loop: Loop = {
id: loopBlockId,
nodes: findChildNodes(loopBlockId, blocks),
iterations: loopBlock.data?.count || DEFAULT_LOOP_ITERATIONS,
loopType,
enabled: loopBlock.enabled,
}
loop.forEachItems = loopBlock.data?.collection || ''
loop.whileCondition = loopBlock.data?.whileCondition || ''
loop.doWhileCondition = loopBlock.data?.doWhileCondition || ''
return loop
}
/**
* Convert UI parallel block to executor Parallel format
*
* @param parallelBlockId - ID of the parallel block to convert
* @param blocks - Record of all blocks in the workflow
* @returns Parallel object for execution engine or undefined if not a valid parallel block
*/
export function convertParallelBlockToParallel(
parallelBlockId: string,
blocks: Record<string, BlockState>
): Parallel | undefined {
const parallelBlock = blocks[parallelBlockId]
if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined
const parallelType = parallelBlock.data?.parallelType || 'count'
const validParallelTypes = ['collection', 'count'] as const
const validatedParallelType = validParallelTypes.includes(parallelType as any)
? parallelType
: 'collection'
const distribution =
validatedParallelType === 'collection' ? parallelBlock.data?.collection || '' : undefined
const count = parallelBlock.data?.count || 5
const batchSize = clampParallelBatchSize(parallelBlock.data?.batchSize)
return {
id: parallelBlockId,
nodes: findChildNodes(parallelBlockId, blocks),
distribution,
count,
parallelType: validatedParallelType,
batchSize,
enabled: parallelBlock.enabled,
}
}
/**
* Find all nodes that are children of this container (loop or parallel)
*
* @param containerId - ID of the container to find children for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are direct children of this container
*/
export function findChildNodes(containerId: string, blocks: Record<string, BlockState>): string[] {
return Object.values(blocks)
.filter((block) => block.data?.parentId === containerId)
.map((block) => block.id)
}
/**
* Find all descendant nodes, including children, grandchildren, etc.
*
* @param containerId - ID of the container to find descendants for
* @param blocks - Record of all blocks in the workflow
* @returns Array of node IDs that are descendants of this container
*/
export function findAllDescendantNodes(
containerId: string,
blocks: Record<string, BlockState>
): string[] {
const descendants: string[] = []
const visited = new Set<string>()
const stack = [containerId]
while (stack.length > 0) {
const current = stack.pop()!
if (visited.has(current)) continue
visited.add(current)
for (const block of Object.values(blocks)) {
if (block.data?.parentId === current) {
descendants.push(block.id)
stack.push(block.id)
}
}
}
return descendants
}
/**
* Checks if any ancestor container of a block is locked.
* Unlike {@link isBlockProtected}, this ignores the block's own locked state.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if any ancestor is locked
*/
export function isAncestorProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockAncestorLocked(blockId, blocks)
}
/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if any ancestor container is locked.
*
* @param blockId - The ID of the block to check
* @param blocks - Record of all blocks in the workflow
* @returns True if the block is protected
*/
export function isBlockProtected(blockId: string, blocks: Record<string, BlockState>): boolean {
return isWorkflowBlockProtected(blockId, blocks)
}
/**
* Builds a complete collection of loops from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Loop objects for execution engine
*/
export function generateLoopBlocks(blocks: Record<string, BlockState>): Record<string, Loop> {
const loops: Record<string, Loop> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'loop')
.forEach(([id, block]) => {
const loop = convertLoopBlockToLoop(id, blocks)
if (loop) {
loops[id] = loop
}
})
return loops
}
/**
* Builds a complete collection of parallel blocks from the UI blocks
*
* @param blocks - Record of all blocks in the workflow
* @returns Record of Parallel objects for execution engine
*/
export function generateParallelBlocks(
blocks: Record<string, BlockState>
): Record<string, Parallel> {
const parallels: Record<string, Parallel> = {}
Object.entries(blocks)
.filter(([_, block]) => block.type === 'parallel')
.forEach(([id, block]) => {
const parallel = convertParallelBlockToParallel(id, blocks)
if (parallel) {
parallels[id] = parallel
}
})
return parallels
}
@@ -0,0 +1,67 @@
import { validateEdges } from '@/stores/workflows/workflow/edge-validation'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
export interface NormalizationResult {
state: WorkflowState
warnings: string[]
}
function isContainerType(type: string | undefined): boolean {
return type === 'loop' || type === 'parallel'
}
export function normalizeWorkflowState(workflowState: WorkflowState): NormalizationResult {
const warnings: string[] = []
const blocks = structuredClone(workflowState.blocks || {})
for (const [blockId, block] of Object.entries(blocks)) {
if (!block?.type || !block?.name) {
warnings.push(`Dropped invalid block "${blockId}" because it is missing type or name`)
delete blocks[blockId]
}
}
for (const [blockId, block] of Object.entries(blocks)) {
const parentId = block.data?.parentId
if (!parentId) {
continue
}
const parentBlock = blocks[parentId]
const parentIsValidContainer = Boolean(parentBlock && isContainerType(parentBlock.type))
if (!parentIsValidContainer || parentId === blockId) {
warnings.push(`Cleared invalid parentId for block "${blockId}"`)
block.data = {
...(block.data || {}),
parentId: undefined,
extent: undefined,
}
continue
}
if (block.data?.extent !== 'parent') {
block.data = {
...(block.data || {}),
extent: 'parent',
}
}
}
const edgeValidation = validateEdges(workflowState.edges || [], blocks)
warnings.push(
...edgeValidation.dropped.map(({ edge, reason }) => `Dropped edge "${edge.id}": ${reason}`)
)
return {
state: {
...workflowState,
blocks,
edges: edgeValidation.valid,
loops: generateLoopBlocks(blocks),
parallels: generateParallelBlocks(blocks),
},
warnings,
}
}