Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions apps/sim/executor/execution/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,10 @@ describe('DAGExecutor run-from-block snapshot metadata', () => {
version: '1',
blocks: [
createBlock('start', BlockType.STARTER),
createBlock('producer', BlockType.FUNCTION),
{
...createBlock('producer', BlockType.FUNCTION),
metadata: { id: 'function', name: 'Fetch' },
},
createBlock('consumer', BlockType.FUNCTION),
],
connections: [
Expand All @@ -220,7 +223,12 @@ describe('DAGExecutor run-from-block snapshot metadata', () => {
const error = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot).catch((e) => e)

expect(error).toBeInstanceOf(RunFromBlockValidationError)
expect(error.message).toBe('Upstream dependency not executed: producer')
expect(error.message).toBe('Upstream dependency not executed: Fetch')
expect(error.refusal).toEqual({
kind: 'upstream_not_executed',
blockId: 'consumer',
upstreamBlockId: 'producer',
})
})
})

Expand Down
9 changes: 7 additions & 2 deletions apps/sim/executor/execution/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,15 @@ export class DAGExecutor {
const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true })

const executedBlocks = new Set(sourceSnapshot.executedBlocks)
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
const blockNames = new Map<string, string>()
for (const block of this.workflow.blocks) {
if (block.metadata?.name) blockNames.set(block.id, block.metadata.name)
}
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks, blockNames)
if (!validation.valid) {
throw new RunFromBlockValidationError(
validation.error ?? `Cannot run from block: ${startBlockId}`
validation.error ?? `Cannot run from block: ${startBlockId}`,
validation.refusal
)
}

Expand Down
47 changes: 47 additions & 0 deletions apps/sim/executor/utils/run-from-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,53 @@ describe('validateRunFromBlock', () => {
expect(result.error).toContain(outerParallelId)
})

it('names the outermost container for a block in a nested loop, since the inner loop is refused too', () => {
const dag = createDAG([
createNode('B', [], { isLoopNode: true, subflowId: 'inner-loop', subflowType: 'loop' }),
])
dag.loopConfigs.set('outer-loop', {
id: 'outer-loop',
nodes: ['inner-loop'],
iterations: 2,
loopType: 'for',
})
dag.loopConfigs.set('inner-loop', {
id: 'inner-loop',
nodes: ['B'],
iterations: 2,
loopType: 'for',
})

const result = validateRunFromBlock('B', dag, new Set())

expect(result.valid).toBe(false)
expect(result.refusal).toEqual({
kind: 'inside_subflow',
blockId: 'B',
subflowId: 'outer-loop',
subflowType: 'loop',
})
})

it('refuses a parallel child by its workflow id as inside the parallel, not as missing', () => {
// The DAG holds parallel children only as branch clones, never under the workflow id.
const dag = createDAG([
createNode('P₍0₎', [], { isParallelBranch: true, subflowId: 'par', subflowType: 'parallel' }),
])
dag.parallelConfigs.set('par', { id: 'par', nodes: ['P'], count: 2 })

const result = validateRunFromBlock('P', dag, new Set(), new Map([['par', 'Fan out']]))

expect(result.valid).toBe(false)
expect(result.error).toContain('inside parallel: Fan out')
expect(result.refusal).toEqual({
kind: 'inside_subflow',
blockId: 'P',
subflowId: 'par',
subflowType: 'parallel',
})
})

it('rejects container when sentinel-start upstream dependency was not executed', () => {
const loopId = 'loop-container-1'
const sentinelStartId = `loop-${loopId}-sentinel-start`
Expand Down
110 changes: 77 additions & 33 deletions apps/sim/executor/utils/run-from-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,37 @@ export function resolveContainerToSentinelStart(blockId: string, dag: DAG): stri
export interface RunFromBlockValidation {
valid: boolean
error?: string
/** Why the start was refused, by id, for callers that phrase their own guidance. */
refusal?: RunFromBlockRefusal
}

/**
* The structured reason behind a refused run-from-block start. `blockId` is always the
* requested start block.
*/
export type RunFromBlockRefusal =
| { kind: 'block_not_found'; blockId: string }
| {
kind: 'inside_subflow'
blockId: string
subflowId: string
subflowType: 'loop' | 'parallel'
}
| { kind: 'upstream_not_executed'; blockId: string; upstreamBlockId: string }

/**
* A run-from-block start the executor refused before running anything: the block is
* missing, sits inside a loop or parallel, or has an upstream dependency the source
* snapshot never executed. Its own class so the application layer can hand the reason
* to the caller as a validation failure instead of the generic system-error fallback.
*/
export class RunFromBlockValidationError extends Error {
constructor(message: string) {
readonly refusal?: RunFromBlockRefusal

constructor(message: string, refusal?: RunFromBlockRefusal) {
super(message)
this.name = 'RunFromBlockValidationError'
this.refusal = refusal
}
}

Expand Down Expand Up @@ -158,40 +177,48 @@ export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionS
* @param blockId - The block ID to validate
* @param dag - The workflow DAG
* @param executedBlocks - Set of blocks that were executed in the source run
* @param blockNames - Display names by block id, so the message names blocks instead of ids
* @returns Validation result with error message if invalid
*/
export function validateRunFromBlock(
blockId: string,
dag: DAG,
executedBlocks: Set<string>
executedBlocks: Set<string>,
blockNames: ReadonlyMap<string, string> = new Map()
): RunFromBlockValidation {
const label = (id: string) => blockNames.get(id) ?? id
const insideSubflow = (
subflowId: string | undefined,
subflowType: 'loop' | 'parallel'
): RunFromBlockValidation => {
if (!subflowId) {
return { valid: false, error: `Cannot run from block inside ${subflowType}: ${blockId}` }
}
return {
valid: false,
error: `Cannot run from block inside ${subflowType}: ${label(subflowId)}. Run the ${subflowType} or the whole workflow instead.`,
refusal: { kind: 'inside_subflow', blockId, subflowId, subflowType },
}
}

const container = findOutermostContainer(blockId, dag)
if (container) return insideSubflow(container.id, container.type)

const node = dag.nodes.get(blockId)
const isLoopContainer = dag.loopConfigs.has(blockId)
const isParallelContainer = dag.parallelConfigs.has(blockId)
const isContainer = isLoopContainer || isParallelContainer
let validationNode = node

if (!node && !isContainer) {
return { valid: false, error: `Block not found in workflow: ${blockId}` }
return {
valid: false,
error: `Block not found in workflow: ${blockId}`,
refusal: { kind: 'block_not_found', blockId },
}
}

if (isContainer) {
const parentLoopId = findParentLoop(blockId, dag)
if (parentLoopId) {
return {
valid: false,
error: `Cannot run from block inside loop: ${parentLoopId}`,
}
}

const parentParallelId = findParentParallel(blockId, dag)
if (parentParallelId) {
return {
valid: false,
error: `Cannot run from block inside parallel: ${parentParallelId}`,
}
}

const sentinelStartId = resolveContainerToSentinelStart(blockId, dag)
if (!sentinelStartId || !dag.nodes.has(sentinelStartId)) {
return {
Expand All @@ -203,19 +230,8 @@ export function validateRunFromBlock(
}

if (node) {
if (node.metadata.isLoopNode) {
return {
valid: false,
error: `Cannot run from block inside loop: ${node.metadata.subflowId}`,
}
}

if (node.metadata.isParallelBranch) {
return {
valid: false,
error: `Cannot run from block inside parallel: ${node.metadata.subflowId}`,
}
}
if (node.metadata.isLoopNode) return insideSubflow(node.metadata.subflowId, 'loop')
if (node.metadata.isParallelBranch) return insideSubflow(node.metadata.subflowId, 'parallel')

if (node.metadata.isSentinel) {
return { valid: false, error: 'Cannot run from sentinel node' }
Expand All @@ -236,7 +252,8 @@ export function validateRunFromBlock(
if (!executedBlocks.has(sourceId)) {
return {
valid: false,
error: `Upstream dependency not executed: ${sourceId}`,
error: `Upstream dependency not executed: ${label(sourceId)}`,
refusal: { kind: 'upstream_not_executed', blockId, upstreamBlockId: sourceId },
}
}
}
Expand All @@ -245,6 +262,33 @@ export function validateRunFromBlock(
return { valid: true }
}

/**
* The top-level loop or parallel a block sits in, however deeply nested. A nested container
* is refused as a start too, so naming the immediate parent would point at another refusal.
* Parallel children are matched by their workflow id, which the DAG only holds as branch clones.
*/
function findOutermostContainer(
blockId: string,
dag: DAG
): { id: string; type: 'loop' | 'parallel' } | undefined {
let outermost: { id: string; type: 'loop' | 'parallel' } | undefined
const visited = new Set([blockId])
let current = blockId
while (true) {
const loopId = findParentLoop(current, dag)
const parallelId = loopId ? undefined : findParentParallel(current, dag)
const parent = loopId
? { id: loopId, type: 'loop' as const }
: parallelId
? { id: parallelId, type: 'parallel' as const }
: undefined
if (!parent || visited.has(parent.id)) return outermost
visited.add(parent.id)
outermost = parent
current = parent.id
}
}

function findParentLoop(blockId: string, dag: DAG): string | undefined {
for (const [loopId, config] of dag.loopConfigs) {
if (loopId !== blockId && config.nodes?.includes(blockId)) {
Expand Down
Loading
Loading