mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
* Run from block * Fixes * Fix * Fix * Minor improvements * Fix * Fix trace spans * Fix loop l ogs * Change ordering * Run u ntil block * Lint * Clean up * Fix * Allow run from block for triggers * Consolidation * Fix lint * Fix * Fix mock payload * Fix * Fix trigger clear snapshot * Fix loops and parallels * Fix * Cleanup * Fix test * Fix bugs * Catch error * Fix * Fix * I think it works?? * Fix * Fix * Add tests * Fix lint --------- Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
1538 lines
51 KiB
TypeScript
1538 lines
51 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
|
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
|
|
import { computeExecutionSets, validateRunFromBlock } from '@/executor/utils/run-from-block'
|
|
import type { SerializedLoop, SerializedParallel } from '@/serializer/types'
|
|
|
|
/**
|
|
* Helper to extract dirty set from computeExecutionSets
|
|
*/
|
|
function computeDirtySet(dag: DAG, startBlockId: string): Set<string> {
|
|
return computeExecutionSets(dag, startBlockId).dirtySet
|
|
}
|
|
|
|
/**
|
|
* Helper to create a DAG node for testing
|
|
*/
|
|
function createNode(
|
|
id: string,
|
|
outgoingEdges: Array<{ target: string; sourceHandle?: string }> = [],
|
|
metadata: Partial<NodeMetadata> = {}
|
|
): DAGNode {
|
|
const edges = new Map<string, DAGEdge>()
|
|
for (const edge of outgoingEdges) {
|
|
edges.set(edge.target, { target: edge.target, sourceHandle: edge.sourceHandle })
|
|
}
|
|
|
|
return {
|
|
id,
|
|
block: {
|
|
id,
|
|
position: { x: 0, y: 0 },
|
|
config: { tool: 'test', params: {} },
|
|
inputs: {},
|
|
outputs: {},
|
|
metadata: { id: 'test', name: `block-${id}`, category: 'tools' },
|
|
enabled: true,
|
|
},
|
|
incomingEdges: new Set<string>(),
|
|
outgoingEdges: edges,
|
|
metadata: {
|
|
isParallelBranch: false,
|
|
isLoopNode: false,
|
|
isSentinel: false,
|
|
...metadata,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper to create a DAG for testing
|
|
*/
|
|
function createDAG(nodes: DAGNode[]): DAG {
|
|
const nodeMap = new Map<string, DAGNode>()
|
|
for (const node of nodes) {
|
|
nodeMap.set(node.id, node)
|
|
}
|
|
|
|
// Set up incoming edges based on outgoing edges
|
|
for (const node of nodes) {
|
|
for (const [, edge] of node.outgoingEdges) {
|
|
const targetNode = nodeMap.get(edge.target)
|
|
if (targetNode) {
|
|
targetNode.incomingEdges.add(node.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodes: nodeMap,
|
|
loopConfigs: new Map<string, SerializedLoop>(),
|
|
parallelConfigs: new Map<string, SerializedParallel>(),
|
|
}
|
|
}
|
|
|
|
describe('computeDirtySet', () => {
|
|
it('includes start block in dirty set', () => {
|
|
const dag = createDAG([createNode('A'), createNode('B'), createNode('C')])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'B')
|
|
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
})
|
|
|
|
it('includes all downstream blocks in linear workflow', () => {
|
|
// A → B → C → D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'B')
|
|
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.size).toBe(3)
|
|
})
|
|
|
|
it('handles branching paths', () => {
|
|
// A → B → C
|
|
// ↓
|
|
// D → E
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }, { target: 'D' }]),
|
|
createNode('C'),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'B')
|
|
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
expect(dirtySet.size).toBe(4)
|
|
})
|
|
|
|
it('handles convergence points', () => {
|
|
// A → C
|
|
// B → C → D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
// Run from A: should include A, C, D (but not B)
|
|
const dirtySet = computeDirtySet(dag, 'A')
|
|
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.size).toBe(3)
|
|
})
|
|
|
|
it('handles diamond pattern', () => {
|
|
// B
|
|
// ↗ ↘
|
|
// A D
|
|
// ↘ ↗
|
|
// C
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'A')
|
|
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.size).toBe(4)
|
|
})
|
|
|
|
it('stops at graph boundaries', () => {
|
|
// A → B C → D (disconnected)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'A')
|
|
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(false)
|
|
expect(dirtySet.has('D')).toBe(false)
|
|
expect(dirtySet.size).toBe(2)
|
|
})
|
|
|
|
it('handles single node workflow', () => {
|
|
const dag = createDAG([createNode('A')])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'A')
|
|
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.size).toBe(1)
|
|
})
|
|
|
|
it('handles node not in DAG gracefully', () => {
|
|
const dag = createDAG([createNode('A'), createNode('B')])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'nonexistent')
|
|
|
|
// Should just contain the start block ID even if not found
|
|
expect(dirtySet.has('nonexistent')).toBe(true)
|
|
expect(dirtySet.size).toBe(1)
|
|
})
|
|
|
|
it('includes convergent block when running from one branch of parallel', () => {
|
|
// Parallel branches converging:
|
|
// A → B → D
|
|
// A → C → D
|
|
// Running from B should include B and D (but not A or C)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'B')
|
|
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(false)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.size).toBe(2)
|
|
})
|
|
|
|
it('handles running from convergent block itself (all upstream non-dirty)', () => {
|
|
// A → C
|
|
// B → C
|
|
// Running from C should only include C
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'C')
|
|
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.size).toBe(2)
|
|
})
|
|
|
|
it('handles deep downstream chains', () => {
|
|
// A → B → C → D → E → F
|
|
// Running from C should include C, D, E, F
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E', [{ target: 'F' }]),
|
|
createNode('F'),
|
|
])
|
|
|
|
const dirtySet = computeDirtySet(dag, 'C')
|
|
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
expect(dirtySet.has('F')).toBe(true)
|
|
expect(dirtySet.size).toBe(4)
|
|
})
|
|
})
|
|
|
|
describe('validateRunFromBlock', () => {
|
|
it('accepts valid block', () => {
|
|
const dag = createDAG([createNode('A'), createNode('B')])
|
|
const executedBlocks = new Set(['A', 'B'])
|
|
|
|
const result = validateRunFromBlock('A', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
expect(result.error).toBeUndefined()
|
|
})
|
|
|
|
it('rejects block not found in DAG', () => {
|
|
const dag = createDAG([createNode('A')])
|
|
const executedBlocks = new Set(['A', 'B'])
|
|
|
|
const result = validateRunFromBlock('B', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Block not found')
|
|
})
|
|
|
|
it('rejects blocks inside loops', () => {
|
|
const dag = createDAG([createNode('A', [], { isLoopNode: true, loopId: 'loop-1' })])
|
|
const executedBlocks = new Set(['A'])
|
|
|
|
const result = validateRunFromBlock('A', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('inside loop')
|
|
expect(result.error).toContain('loop-1')
|
|
})
|
|
|
|
it('rejects blocks inside parallels', () => {
|
|
const dag = createDAG([
|
|
createNode('A', [], { isParallelBranch: true, parallelId: 'parallel-1' }),
|
|
])
|
|
const executedBlocks = new Set(['A'])
|
|
|
|
const result = validateRunFromBlock('A', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('inside parallel')
|
|
expect(result.error).toContain('parallel-1')
|
|
})
|
|
|
|
it('rejects sentinel nodes', () => {
|
|
const dag = createDAG([createNode('A', [], { isSentinel: true, sentinelType: 'start' })])
|
|
const executedBlocks = new Set(['A'])
|
|
|
|
const result = validateRunFromBlock('A', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('sentinel')
|
|
})
|
|
|
|
it('rejects blocks with unexecuted upstream dependencies', () => {
|
|
// X → A → B, where A was not executed but B depends on A
|
|
// X is the entry point (no incoming edges), A is a regular block
|
|
const dag = createDAG([
|
|
createNode('X', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
])
|
|
const executedBlocks = new Set(['X']) // X executed but A was not
|
|
|
|
const result = validateRunFromBlock('B', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Upstream dependency not executed')
|
|
})
|
|
|
|
it('allows running from block when immediate predecessor was executed (ignores transitive)', () => {
|
|
// A → X → B → C, where X is new (not executed)
|
|
// Running from C is allowed because B (immediate predecessor) was executed
|
|
// C will use B's cached output - doesn't matter that X is new
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'X' }]),
|
|
createNode('X', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
const executedBlocks = new Set(['A', 'B', 'C']) // X was not executed (new block)
|
|
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
// Valid because C's immediate predecessor B was executed
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('allows blocks with no dependencies even if not previously executed', () => {
|
|
// A and B are independent (no edges)
|
|
const dag = createDAG([createNode('A'), createNode('B')])
|
|
const executedBlocks = new Set(['A']) // B was not executed but has no deps
|
|
|
|
const result = validateRunFromBlock('B', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true) // B has no incoming edges, so it's valid
|
|
})
|
|
|
|
it('accepts regular executed block', () => {
|
|
const dag = createDAG([
|
|
createNode('trigger', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
])
|
|
const executedBlocks = new Set(['trigger', 'A', 'B'])
|
|
|
|
const result = validateRunFromBlock('A', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('accepts loop container when executed', () => {
|
|
// Loop container with sentinel nodes
|
|
const loopId = 'loop-container-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any)
|
|
const executedBlocks = new Set(['A', loopId, sentinelStartId, 'B', sentinelEndId, 'C'])
|
|
|
|
const result = validateRunFromBlock(loopId, dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('accepts parallel container when executed', () => {
|
|
// Parallel container with sentinel nodes
|
|
const parallelId = 'parallel-container-1'
|
|
const sentinelStartId = `parallel-${parallelId}-sentinel-start`
|
|
const sentinelEndId = `parallel-${parallelId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B₍0₎' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
parallelId,
|
|
}),
|
|
createNode('B₍0₎', [{ target: sentinelEndId }], { isParallelBranch: true, parallelId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
parallelId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any)
|
|
const executedBlocks = new Set(['A', parallelId, sentinelStartId, 'B₍0₎', sentinelEndId, 'C'])
|
|
|
|
const result = validateRunFromBlock(parallelId, dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('allows loop container with no upstream dependencies', () => {
|
|
// Loop containers are validated via their sentinel nodes, not incoming edges on the container itself
|
|
// If the loop has no upstream dependencies, it should be valid
|
|
const loopId = 'loop-container-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const dag = createDAG([
|
|
createNode(sentinelStartId, [], { isSentinel: true, sentinelType: 'start', loopId }),
|
|
])
|
|
dag.loopConfigs.set(loopId, { id: loopId, nodes: [], iterations: 3, loopType: 'for' } as any)
|
|
const executedBlocks = new Set<string>() // Nothing executed but loop has no deps
|
|
|
|
const result = validateRunFromBlock(loopId, dag, executedBlocks)
|
|
|
|
// Loop container validation doesn't check incoming edges (containers don't have nodes in dag.nodes)
|
|
// So this is valid - the loop can start fresh
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('computeDirtySet with containers', () => {
|
|
it('includes loop container and all downstream when running from loop', () => {
|
|
// A → loop-sentinel-start → B (inside loop) → loop-sentinel-end → C
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(loopId, { id: loopId, nodes: ['B'], iterations: 3, loopType: 'for' } as any)
|
|
|
|
const dirtySet = computeDirtySet(dag, loopId)
|
|
|
|
// Should include loop container, sentinel-start, B, sentinel-end, C
|
|
expect(dirtySet.has(loopId)).toBe(true)
|
|
expect(dirtySet.has(sentinelStartId)).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has(sentinelEndId)).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
// Should NOT include A (upstream)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
})
|
|
|
|
it('includes parallel container and all downstream when running from parallel', () => {
|
|
// A → parallel-sentinel-start → B₍0₎ → parallel-sentinel-end → C
|
|
const parallelId = 'parallel-1'
|
|
const sentinelStartId = `parallel-${parallelId}-sentinel-start`
|
|
const sentinelEndId = `parallel-${parallelId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B₍0₎' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
parallelId,
|
|
}),
|
|
createNode('B₍0₎', [{ target: sentinelEndId }], { isParallelBranch: true, parallelId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
parallelId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.parallelConfigs.set(parallelId, { id: parallelId, nodes: ['B'], count: 2 } as any)
|
|
|
|
const dirtySet = computeDirtySet(dag, parallelId)
|
|
|
|
// Should include parallel container, sentinel-start, B₍0₎, sentinel-end, C
|
|
expect(dirtySet.has(parallelId)).toBe(true)
|
|
expect(dirtySet.has(sentinelStartId)).toBe(true)
|
|
expect(dirtySet.has('B₍0₎')).toBe(true)
|
|
expect(dirtySet.has(sentinelEndId)).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
// Should NOT include A (upstream)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('computeExecutionSets upstream set', () => {
|
|
it('includes all upstream blocks in linear workflow', () => {
|
|
// A → B → C → D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { upstreamSet } = computeExecutionSets(dag, 'C')
|
|
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('B')).toBe(true)
|
|
expect(upstreamSet.has('C')).toBe(false) // start block not in upstream
|
|
expect(upstreamSet.has('D')).toBe(false) // downstream
|
|
})
|
|
|
|
it('includes all branches in convergent upstream', () => {
|
|
// A → C
|
|
// B → C → D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { upstreamSet } = computeExecutionSets(dag, 'C')
|
|
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('B')).toBe(true)
|
|
expect(upstreamSet.has('C')).toBe(false)
|
|
expect(upstreamSet.has('D')).toBe(false)
|
|
})
|
|
|
|
it('excludes parallel branches not in upstream path', () => {
|
|
// A → B → D
|
|
// A → C → D
|
|
// Running from B: upstream is A only, not C
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { upstreamSet, dirtySet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Upstream should only contain A
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('C')).toBe(false) // parallel branch, not upstream of B
|
|
// Dirty should contain B and D
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(false)
|
|
})
|
|
|
|
it('handles diamond pattern upstream correctly', () => {
|
|
// B
|
|
// ↗ ↘
|
|
// A D → E
|
|
// ↘ ↗
|
|
// C
|
|
// Running from D: upstream should be A, B, C
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
const { upstreamSet, dirtySet } = computeExecutionSets(dag, 'D')
|
|
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('B')).toBe(true)
|
|
expect(upstreamSet.has('C')).toBe(true)
|
|
expect(upstreamSet.has('D')).toBe(false)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
})
|
|
|
|
it('returns empty upstream set for root block', () => {
|
|
const dag = createDAG([createNode('A', [{ target: 'B' }]), createNode('B')])
|
|
|
|
const { upstreamSet } = computeExecutionSets(dag, 'A')
|
|
|
|
expect(upstreamSet.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('computeExecutionSets reachableUpstreamSet', () => {
|
|
it('includes sibling branches for convergent downstream blocks', () => {
|
|
// A → C
|
|
// B → C
|
|
// Running from A: C is dirty and may reference B, so B should be in reachableUpstreamSet
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'A')
|
|
|
|
// Dirty should be A and C
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
|
|
// Upstream of start block (A) is empty
|
|
expect(upstreamSet.size).toBe(0)
|
|
|
|
// But reachableUpstreamSet should include B because C (dirty) has B as upstream
|
|
expect(reachableUpstreamSet.has('B')).toBe(true)
|
|
expect(reachableUpstreamSet.has('A')).toBe(false) // A is in dirty set
|
|
expect(reachableUpstreamSet.has('C')).toBe(false) // C is in dirty set
|
|
})
|
|
|
|
it('includes sibling branches when running from the other branch', () => {
|
|
// A → C
|
|
// B → C
|
|
// Running from B: C is dirty and may reference A, so A should be in reachableUpstreamSet
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Dirty should be B and C
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
|
|
// Upstream of start block (B) is empty
|
|
expect(upstreamSet.size).toBe(0)
|
|
|
|
// reachableUpstreamSet should include A because C (dirty) has A as upstream
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.has('B')).toBe(false) // B is in dirty set
|
|
expect(reachableUpstreamSet.has('C')).toBe(false) // C is in dirty set
|
|
})
|
|
|
|
it('includes all branches in diamond pattern when running from one branch', () => {
|
|
// A → B → D
|
|
// A → C → D
|
|
// Running from B: dirty is {B, D}, D may reference C, so C should be in reachableUpstreamSet
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Dirty should be B and D
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(false)
|
|
|
|
// Upstream of start block (B) is A
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('C')).toBe(false)
|
|
|
|
// reachableUpstreamSet should include A and C
|
|
// A is upstream of B, C is upstream of D (sibling branch)
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.has('C')).toBe(true)
|
|
})
|
|
|
|
it('equals upstream set when no sibling branches exist', () => {
|
|
// A → B → C → D
|
|
// Running from B: no sibling branches, reachableUpstreamSet should equal upstreamSet
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Both should be the same for linear workflow
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.size).toBe(upstreamSet.size)
|
|
})
|
|
|
|
it('handles complex multi-branch convergence', () => {
|
|
// X → A → D
|
|
// Y → B → D → E
|
|
// Z → C → D
|
|
// Running from A: dirty is {A, D, E}, D may reference B and C
|
|
const dag = createDAG([
|
|
createNode('X', [{ target: 'A' }]),
|
|
createNode('Y', [{ target: 'B' }]),
|
|
createNode('Z', [{ target: 'C' }]),
|
|
createNode('A', [{ target: 'D' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'A')
|
|
|
|
// Dirty: A, D, E
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
|
|
// Upstream of A: just X
|
|
expect(upstreamSet.has('X')).toBe(true)
|
|
expect(upstreamSet.size).toBe(1)
|
|
|
|
// reachableUpstreamSet: X (upstream of A), Y, B (upstream of D), Z, C (upstream of D)
|
|
expect(reachableUpstreamSet.has('X')).toBe(true)
|
|
expect(reachableUpstreamSet.has('Y')).toBe(true)
|
|
expect(reachableUpstreamSet.has('Z')).toBe(true)
|
|
expect(reachableUpstreamSet.has('B')).toBe(true)
|
|
expect(reachableUpstreamSet.has('C')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('run from trigger scenarios', () => {
|
|
it('allows running from trigger block (entry point with no upstream)', () => {
|
|
// Trigger → A → B
|
|
const dag = createDAG([
|
|
createNode('trigger', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
])
|
|
const executedBlocks = new Set(['trigger', 'A', 'B'])
|
|
|
|
const result = validateRunFromBlock('trigger', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('computes dirty set correctly when running from trigger', () => {
|
|
// Trigger → A → B → C
|
|
const dag = createDAG([
|
|
createNode('trigger', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet } = computeExecutionSets(dag, 'trigger')
|
|
|
|
// All blocks should be dirty when running from trigger
|
|
expect(dirtySet.has('trigger')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.size).toBe(4)
|
|
|
|
// No upstream for trigger
|
|
expect(upstreamSet.size).toBe(0)
|
|
})
|
|
|
|
it('handles multiple triggers with reference to other trigger being undefined', () => {
|
|
// Trigger1 → A → C
|
|
// Trigger2 → B → C
|
|
// Running from Trigger1: B should be in reachableUpstreamSet (for C's reference)
|
|
// but Trigger2's output should not be required
|
|
const dag = createDAG([
|
|
createNode('trigger1', [{ target: 'A' }]),
|
|
createNode('trigger2', [{ target: 'B' }]),
|
|
createNode('A', [{ target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
const { dirtySet, reachableUpstreamSet } = computeExecutionSets(dag, 'trigger1')
|
|
|
|
// Dirty: trigger1, A, C
|
|
expect(dirtySet.has('trigger1')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
|
|
// trigger2 and B are NOT dirty
|
|
expect(dirtySet.has('trigger2')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
|
|
// But B should be in reachableUpstreamSet because C may reference it
|
|
// trigger2 should also be in reachableUpstreamSet as upstream of B
|
|
expect(reachableUpstreamSet.has('B')).toBe(true)
|
|
expect(reachableUpstreamSet.has('trigger2')).toBe(true)
|
|
})
|
|
|
|
it('validates trigger block even when not previously executed', () => {
|
|
// Trigger blocks are entry points, so they don't need upstream deps
|
|
const dag = createDAG([createNode('trigger', [{ target: 'A' }]), createNode('A')])
|
|
const executedBlocks = new Set<string>() // Nothing executed yet
|
|
|
|
const result = validateRunFromBlock('trigger', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('allows running from webhook trigger (starter block)', () => {
|
|
// Webhook/Start trigger with input format → A → B
|
|
const dag = createDAG([
|
|
createNode('starter', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
])
|
|
const executedBlocks = new Set(['starter', 'A', 'B'])
|
|
|
|
const result = validateRunFromBlock('starter', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('includes webhook input format in dirty set computation', () => {
|
|
// Webhook trigger has input format that downstream blocks can reference
|
|
// Starter → Process → Output
|
|
const dag = createDAG([
|
|
createNode('webhook-starter', [{ target: 'process' }]),
|
|
createNode('process', [{ target: 'output' }]),
|
|
createNode('output'),
|
|
])
|
|
|
|
const { dirtySet } = computeExecutionSets(dag, 'webhook-starter')
|
|
|
|
// All should be re-executed to process new webhook input
|
|
expect(dirtySet.has('webhook-starter')).toBe(true)
|
|
expect(dirtySet.has('process')).toBe(true)
|
|
expect(dirtySet.has('output')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('run from subflow (loop) scenarios', () => {
|
|
it('allows running from loop container', () => {
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(loopId, {
|
|
id: loopId,
|
|
nodes: ['B'],
|
|
iterations: 3,
|
|
loopType: 'for',
|
|
} as any)
|
|
const executedBlocks = new Set(['A', sentinelStartId, 'B', sentinelEndId, 'C'])
|
|
|
|
const result = validateRunFromBlock(loopId, dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('rejects running from block inside loop', () => {
|
|
const loopId = 'loop-1'
|
|
const dag = createDAG([createNode('inside-loop', [], { isLoopNode: true, loopId })])
|
|
const executedBlocks = new Set(['inside-loop'])
|
|
|
|
const result = validateRunFromBlock('inside-loop', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('inside loop')
|
|
})
|
|
|
|
it('computes dirty set for loop to include all iterations', () => {
|
|
// When running from loop, all internal blocks get re-executed for all iterations
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: 'C' }], { isLoopNode: true, loopId }),
|
|
createNode('C', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'D' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('D'),
|
|
])
|
|
dag.loopConfigs.set(loopId, {
|
|
id: loopId,
|
|
nodes: ['B', 'C'],
|
|
iterations: 5,
|
|
loopType: 'for',
|
|
} as any)
|
|
|
|
const { dirtySet } = computeExecutionSets(dag, loopId)
|
|
|
|
// Loop container, sentinels, inner blocks, and downstream should be dirty
|
|
expect(dirtySet.has(loopId)).toBe(true)
|
|
expect(dirtySet.has(sentinelStartId)).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has(sentinelEndId)).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
|
|
// Upstream should not be dirty
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
})
|
|
|
|
it('handles loop.results reference outside loop scope', () => {
|
|
// A → Loop[B] → C (references <loop.results>)
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'), // This block can reference <loop.results>
|
|
])
|
|
dag.loopConfigs.set(loopId, {
|
|
id: loopId,
|
|
nodes: ['B'],
|
|
iterations: 3,
|
|
loopType: 'forEach',
|
|
} as any)
|
|
|
|
const { dirtySet, reachableUpstreamSet } = computeExecutionSets(dag, 'C')
|
|
|
|
// Only C should be dirty when running from C
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.size).toBe(1)
|
|
|
|
// Loop sentinels and internals should be in reachableUpstream
|
|
expect(reachableUpstreamSet.has(sentinelEndId)).toBe(true)
|
|
expect(reachableUpstreamSet.has('B')).toBe(true)
|
|
expect(reachableUpstreamSet.has(sentinelStartId)).toBe(true)
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
})
|
|
|
|
it('handles nested loops correctly', () => {
|
|
// Outer loop contains inner loop
|
|
const outerLoopId = 'outer-loop'
|
|
const innerLoopId = 'inner-loop'
|
|
const outerStartId = `loop-${outerLoopId}-sentinel-start`
|
|
const outerEndId = `loop-${outerLoopId}-sentinel-end`
|
|
const innerStartId = `loop-${innerLoopId}-sentinel-start`
|
|
const innerEndId = `loop-${innerLoopId}-sentinel-end`
|
|
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: outerStartId }]),
|
|
createNode(outerStartId, [{ target: innerStartId }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId: outerLoopId,
|
|
}),
|
|
createNode(innerStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId: innerLoopId,
|
|
isLoopNode: true,
|
|
}),
|
|
createNode('B', [{ target: innerEndId }], {
|
|
isLoopNode: true,
|
|
loopId: innerLoopId,
|
|
}),
|
|
createNode(innerEndId, [{ target: outerEndId }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId: innerLoopId,
|
|
isLoopNode: true,
|
|
}),
|
|
createNode(outerEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId: outerLoopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(outerLoopId, {
|
|
id: outerLoopId,
|
|
nodes: [innerStartId, 'B', innerEndId],
|
|
iterations: 2,
|
|
loopType: 'for',
|
|
} as any)
|
|
dag.loopConfigs.set(innerLoopId, {
|
|
id: innerLoopId,
|
|
nodes: ['B'],
|
|
iterations: 3,
|
|
loopType: 'for',
|
|
} as any)
|
|
|
|
const { dirtySet } = computeExecutionSets(dag, outerLoopId)
|
|
|
|
// Everything from outer loop onwards should be dirty
|
|
expect(dirtySet.has(outerLoopId)).toBe(true)
|
|
expect(dirtySet.has(outerStartId)).toBe(true)
|
|
expect(dirtySet.has(innerStartId)).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has(innerEndId)).toBe(true)
|
|
expect(dirtySet.has(outerEndId)).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('branching variable resolution scenarios', () => {
|
|
it('parallel branches do not know about each other (no cross-branch in dirty)', () => {
|
|
// A splits into B and C (parallel branches), both merge at D
|
|
// Running from B: B and D are dirty, but C is NOT dirty
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { dirtySet } = computeExecutionSets(dag, 'B')
|
|
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(false) // Parallel branch NOT in dirty
|
|
})
|
|
|
|
it('after convergence, downstream knows about both branches', () => {
|
|
// A → B → D → E
|
|
// A → C → D → E
|
|
// Running from B: D may reference C, and E may reference both B and C
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
const { dirtySet, reachableUpstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Dirty: B, D, E
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
|
|
// reachableUpstreamSet includes C (sibling branch that D can reference)
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.has('C')).toBe(true)
|
|
})
|
|
|
|
it('variable not in upstream should not resolve (not in reachableUpstreamSet)', () => {
|
|
// Completely separate paths:
|
|
// A → B → C
|
|
// X → Y → Z
|
|
// Running from B: Y and Z should NOT be in any set
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
createNode('X', [{ target: 'Y' }]),
|
|
createNode('Y', [{ target: 'Z' }]),
|
|
createNode('Z'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
// Only A → B → C path affected
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
|
|
// X → Y → Z completely isolated
|
|
expect(dirtySet.has('X')).toBe(false)
|
|
expect(dirtySet.has('Y')).toBe(false)
|
|
expect(dirtySet.has('Z')).toBe(false)
|
|
expect(upstreamSet.has('X')).toBe(false)
|
|
expect(reachableUpstreamSet.has('X')).toBe(false)
|
|
expect(reachableUpstreamSet.has('Y')).toBe(false)
|
|
expect(reachableUpstreamSet.has('Z')).toBe(false)
|
|
})
|
|
|
|
it('branch and reconnect: running from middle of second branch includes convergence correctly', () => {
|
|
// A → B1 → B2 → D
|
|
// A → C1 → C2 → D → E
|
|
// Running from C2: dirty is {C2, D, E}
|
|
// C2 doesn't know about B1 or B2 until D converges
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B1' }, { target: 'C1' }]),
|
|
createNode('B1', [{ target: 'B2' }]),
|
|
createNode('B2', [{ target: 'D' }]),
|
|
createNode('C1', [{ target: 'C2' }]),
|
|
createNode('C2', [{ target: 'D' }]),
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, 'C2')
|
|
|
|
// Dirty: C2, D, E
|
|
expect(dirtySet.has('C2')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('E')).toBe(true)
|
|
|
|
// NOT dirty: A, B1, B2, C1
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B1')).toBe(false)
|
|
expect(dirtySet.has('B2')).toBe(false)
|
|
expect(dirtySet.has('C1')).toBe(false)
|
|
|
|
// Upstream of C2: A, C1
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
expect(upstreamSet.has('C1')).toBe(true)
|
|
|
|
// reachableUpstreamSet: A, C1 (upstream of C2) + B1, B2 (upstream of D's other branch)
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.has('C1')).toBe(true)
|
|
expect(reachableUpstreamSet.has('B1')).toBe(true)
|
|
expect(reachableUpstreamSet.has('B2')).toBe(true)
|
|
})
|
|
|
|
it('deep nested convergence with multiple levels', () => {
|
|
// Complex graph:
|
|
// B → D
|
|
// ↗ ↘
|
|
// A F → G
|
|
// ↘ ↗
|
|
// C → E
|
|
// Running from D: dirty is {D, F, G}, but E is in reachableUpstreamSet (via F)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('C', [{ target: 'E' }]),
|
|
createNode('D', [{ target: 'F' }]),
|
|
createNode('E', [{ target: 'F' }]),
|
|
createNode('F', [{ target: 'G' }]),
|
|
createNode('G'),
|
|
])
|
|
|
|
const { dirtySet, reachableUpstreamSet } = computeExecutionSets(dag, 'D')
|
|
|
|
// Dirty: D, F, G
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('F')).toBe(true)
|
|
expect(dirtySet.has('G')).toBe(true)
|
|
|
|
// NOT dirty: A, B, C, E
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
expect(dirtySet.has('B')).toBe(false)
|
|
expect(dirtySet.has('C')).toBe(false)
|
|
expect(dirtySet.has('E')).toBe(false)
|
|
|
|
// reachableUpstreamSet: A, B (upstream of D) + C, E (upstream of F's other branch)
|
|
expect(reachableUpstreamSet.has('A')).toBe(true)
|
|
expect(reachableUpstreamSet.has('B')).toBe(true)
|
|
expect(reachableUpstreamSet.has('C')).toBe(true)
|
|
expect(reachableUpstreamSet.has('E')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('run until block scenarios', () => {
|
|
it('validates that run-until target block must exist in DAG', () => {
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
const executedBlocks = new Set<string>()
|
|
|
|
// The run-until target should be a valid block
|
|
const result = validateRunFromBlock('nonexistent', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Block not found')
|
|
})
|
|
|
|
it('run-until for loop container includes sentinel-end', () => {
|
|
// When stopAfterBlockId is a loop, it resolves to sentinel-end
|
|
// This ensures all iterations complete
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(loopId, {
|
|
id: loopId,
|
|
nodes: ['B'],
|
|
iterations: 3,
|
|
loopType: 'for',
|
|
} as any)
|
|
|
|
// Dirty set from A should include everything
|
|
const { dirtySet } = computeExecutionSets(dag, 'A')
|
|
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has(sentinelStartId)).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has(sentinelEndId)).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
})
|
|
|
|
it('run-until for parallel container includes sentinel-end', () => {
|
|
const parallelId = 'parallel-1'
|
|
const sentinelStartId = `parallel-${parallelId}-sentinel-start`
|
|
const sentinelEndId = `parallel-${parallelId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B₍0₎' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
parallelId,
|
|
}),
|
|
createNode('B₍0₎', [{ target: sentinelEndId }], {
|
|
isParallelBranch: true,
|
|
parallelId,
|
|
}),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
parallelId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.parallelConfigs.set(parallelId, {
|
|
id: parallelId,
|
|
nodes: ['B'],
|
|
count: 2,
|
|
} as any)
|
|
|
|
// Ensure parallel container is valid to run to
|
|
const result = validateRunFromBlock(parallelId, dag, new Set(['A']))
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('rejects run-until for trigger blocks', () => {
|
|
// Triggers are entry points, not valid as "run until" targets
|
|
// (You can't stop "until" a trigger since triggers start execution)
|
|
const dag = createDAG([
|
|
createNode('trigger', [{ target: 'A' }]),
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B'),
|
|
])
|
|
|
|
// When considering "run until trigger", the trigger has no incoming edges
|
|
// so there's nothing to "run until"
|
|
const { dirtySet } = computeExecutionSets(dag, 'trigger')
|
|
|
|
// Running FROM trigger makes everything dirty
|
|
expect(dirtySet.has('trigger')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(true)
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('run-until followed by run-from-block state preservation', () => {
|
|
it('validates run-from-block after partial execution respects executed blocks', () => {
|
|
// Scenario: Run until B completed, now run from B
|
|
// A → B → C → D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
// After run-until B: A and B were executed
|
|
const executedBlocks = new Set(['A', 'B'])
|
|
|
|
// Now run from B: should be valid since B was executed
|
|
const result = validateRunFromBlock('B', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('computes dirty set correctly after partial execution', () => {
|
|
// A → B → C → D
|
|
// After run-until B: A and B executed
|
|
// Run from B: dirty should be B, C, D
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C', [{ target: 'D' }]),
|
|
createNode('D'),
|
|
])
|
|
|
|
const { dirtySet, upstreamSet } = computeExecutionSets(dag, 'B')
|
|
|
|
expect(dirtySet.has('B')).toBe(true)
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.has('D')).toBe(true)
|
|
expect(dirtySet.has('A')).toBe(false)
|
|
|
|
expect(upstreamSet.has('A')).toBe(true)
|
|
})
|
|
|
|
it('rejects run-from-block if upstream dependency not executed', () => {
|
|
// A → B → C
|
|
// After run-until C but B failed/wasn't executed
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
// Only A executed, B was not
|
|
const executedBlocks = new Set(['A'])
|
|
|
|
// Run from C: should fail because B (immediate upstream) not executed
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Upstream dependency not executed')
|
|
expect(result.error).toContain('B')
|
|
})
|
|
|
|
it('preserves loop execution state after run-until loop completes', () => {
|
|
const loopId = 'loop-1'
|
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: sentinelStartId }]),
|
|
createNode(sentinelStartId, [{ target: 'B' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'start',
|
|
loopId,
|
|
}),
|
|
createNode('B', [{ target: sentinelEndId }], { isLoopNode: true, loopId }),
|
|
createNode(sentinelEndId, [{ target: 'C' }], {
|
|
isSentinel: true,
|
|
sentinelType: 'end',
|
|
loopId,
|
|
}),
|
|
createNode('C'),
|
|
])
|
|
dag.loopConfigs.set(loopId, {
|
|
id: loopId,
|
|
nodes: ['B'],
|
|
iterations: 3,
|
|
loopType: 'for',
|
|
} as any)
|
|
|
|
// After run-until loop completes: all loop iterations done
|
|
const executedBlocks = new Set(['A', sentinelStartId, 'B', sentinelEndId])
|
|
|
|
// Run from C: valid because sentinel-end (immediate upstream) was executed
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
|
|
// Dirty set for running from C
|
|
const { dirtySet } = computeExecutionSets(dag, 'C')
|
|
|
|
expect(dirtySet.has('C')).toBe(true)
|
|
expect(dirtySet.size).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe('upstream block addition/deletion scenarios', () => {
|
|
it('disables run-from-block when new upstream block added (dependency not executed)', () => {
|
|
// Original: A → C
|
|
// Modified: A → B → C (B is new)
|
|
// Running from C should be invalid because B wasn't executed
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'C' }]), // New block
|
|
createNode('C'),
|
|
])
|
|
|
|
// Only A and C were executed in previous run (before B existed)
|
|
const executedBlocks = new Set(['A', 'C'])
|
|
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Upstream dependency not executed')
|
|
expect(result.error).toContain('B')
|
|
})
|
|
|
|
it('allows run-from-block when upstream block deleted (no missing dependency)', () => {
|
|
// Original: A → B → C
|
|
// Modified: A → C (B deleted, edge now A → C)
|
|
// Running from C should be valid because A was executed
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'C' }]), // Direct edge, B removed
|
|
createNode('C'),
|
|
])
|
|
|
|
// A, B, C were all executed in previous run
|
|
const executedBlocks = new Set(['A', 'B', 'C'])
|
|
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('handles block replacement (same position, different block)', () => {
|
|
// Original: A → OldB → C
|
|
// Modified: A → NewB → C (OldB replaced with NewB)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'NewB' }]),
|
|
createNode('NewB', [{ target: 'C' }]),
|
|
createNode('C'),
|
|
])
|
|
|
|
// OldB was executed, but NewB wasn't
|
|
const executedBlocks = new Set(['A', 'OldB', 'C'])
|
|
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('Upstream dependency not executed')
|
|
expect(result.error).toContain('NewB')
|
|
})
|
|
|
|
it('allows run-from-block when parallel upstream branch added', () => {
|
|
// Original: A → C
|
|
// Modified: A → B → C (parallel branch)
|
|
// A → C
|
|
// If C has multiple upstreams and at least one was executed
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'C' }]),
|
|
createNode('B', [{ target: 'C' }]), // New parallel path
|
|
createNode('C'),
|
|
])
|
|
|
|
// Only A and direct A→C path executed, B not executed
|
|
const executedBlocks = new Set(['A', 'C'])
|
|
|
|
// C has two incoming: A and B
|
|
// A was executed (entry point), B was not
|
|
// This should be invalid because B is an immediate upstream
|
|
const result = validateRunFromBlock('C', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('B')
|
|
})
|
|
|
|
it('validates correctly when intermediate block reconnected', () => {
|
|
// Original: A → B → C → D
|
|
// Modified: A → B → D (C deleted, B now connects to D)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }]),
|
|
createNode('B', [{ target: 'D' }]), // Now connects directly to D
|
|
createNode('D'),
|
|
])
|
|
|
|
// A, B, C, D all executed in previous run
|
|
const executedBlocks = new Set(['A', 'B', 'C', 'D'])
|
|
|
|
// Run from D: B was executed, so valid
|
|
const result = validateRunFromBlock('D', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(true)
|
|
})
|
|
|
|
it('handles complex graph modification with multiple changes', () => {
|
|
// Original: A → B → D → E
|
|
// A → C → D → E
|
|
// Modified: A → B → D → E (C path removed)
|
|
// A → X → D → E (X is new)
|
|
const dag = createDAG([
|
|
createNode('A', [{ target: 'B' }, { target: 'X' }]),
|
|
createNode('B', [{ target: 'D' }]),
|
|
createNode('X', [{ target: 'D' }]), // New block
|
|
createNode('D', [{ target: 'E' }]),
|
|
createNode('E'),
|
|
])
|
|
|
|
// A, B, C, D, E executed (C no longer exists, X is new)
|
|
const executedBlocks = new Set(['A', 'B', 'C', 'D', 'E'])
|
|
|
|
// Run from D: B was executed, but X was not
|
|
const result = validateRunFromBlock('D', dag, executedBlocks)
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toContain('X')
|
|
})
|
|
})
|