@@ -29,18 +29,37 @@ export function resolveContainerToSentinelStart(blockId: string, dag: DAG): stri
2929export interface RunFromBlockValidation {
3030 valid : boolean
3131 error ?: string
32+ /** Why the start was refused, by id, for callers that phrase their own guidance. */
33+ refusal ?: RunFromBlockRefusal
3234}
3335
36+ /**
37+ * The structured reason behind a refused run-from-block start. `blockId` is always the
38+ * requested start block.
39+ */
40+ export type RunFromBlockRefusal =
41+ | { kind : 'block_not_found' ; blockId : string }
42+ | {
43+ kind : 'inside_subflow'
44+ blockId : string
45+ subflowId : string
46+ subflowType : 'loop' | 'parallel'
47+ }
48+ | { kind : 'upstream_not_executed' ; blockId : string ; upstreamBlockId : string }
49+
3450/**
3551 * A run-from-block start the executor refused before running anything: the block is
3652 * missing, sits inside a loop or parallel, or has an upstream dependency the source
3753 * snapshot never executed. Its own class so the application layer can hand the reason
3854 * to the caller as a validation failure instead of the generic system-error fallback.
3955 */
4056export class RunFromBlockValidationError extends Error {
41- constructor ( message : string ) {
57+ readonly refusal ?: RunFromBlockRefusal
58+
59+ constructor ( message : string , refusal ?: RunFromBlockRefusal ) {
4260 super ( message )
4361 this . name = 'RunFromBlockValidationError'
62+ this . refusal = refusal
4463 }
4564}
4665
@@ -158,40 +177,48 @@ export function computeExecutionSets(dag: DAG, startBlockId: string): ExecutionS
158177 * @param blockId - The block ID to validate
159178 * @param dag - The workflow DAG
160179 * @param executedBlocks - Set of blocks that were executed in the source run
180+ * @param blockNames - Display names by block id, so the message names blocks instead of ids
161181 * @returns Validation result with error message if invalid
162182 */
163183export function validateRunFromBlock (
164184 blockId : string ,
165185 dag : DAG ,
166- executedBlocks : Set < string >
186+ executedBlocks : Set < string > ,
187+ blockNames : ReadonlyMap < string , string > = new Map ( )
167188) : RunFromBlockValidation {
189+ const label = ( id : string ) => blockNames . get ( id ) ?? id
190+ const insideSubflow = (
191+ subflowId : string | undefined ,
192+ subflowType : 'loop' | 'parallel'
193+ ) : RunFromBlockValidation => {
194+ if ( ! subflowId ) {
195+ return { valid : false , error : `Cannot run from block inside ${ subflowType } : ${ blockId } ` }
196+ }
197+ return {
198+ valid : false ,
199+ error : `Cannot run from block inside ${ subflowType } : ${ label ( subflowId ) } . Run the ${ subflowType } or the whole workflow instead.` ,
200+ refusal : { kind : 'inside_subflow' , blockId, subflowId, subflowType } ,
201+ }
202+ }
203+
204+ const container = findOutermostContainer ( blockId , dag )
205+ if ( container ) return insideSubflow ( container . id , container . type )
206+
168207 const node = dag . nodes . get ( blockId )
169208 const isLoopContainer = dag . loopConfigs . has ( blockId )
170209 const isParallelContainer = dag . parallelConfigs . has ( blockId )
171210 const isContainer = isLoopContainer || isParallelContainer
172211 let validationNode = node
173212
174213 if ( ! node && ! isContainer ) {
175- return { valid : false , error : `Block not found in workflow: ${ blockId } ` }
214+ return {
215+ valid : false ,
216+ error : `Block not found in workflow: ${ blockId } ` ,
217+ refusal : { kind : 'block_not_found' , blockId } ,
218+ }
176219 }
177220
178221 if ( isContainer ) {
179- const parentLoopId = findParentLoop ( blockId , dag )
180- if ( parentLoopId ) {
181- return {
182- valid : false ,
183- error : `Cannot run from block inside loop: ${ parentLoopId } ` ,
184- }
185- }
186-
187- const parentParallelId = findParentParallel ( blockId , dag )
188- if ( parentParallelId ) {
189- return {
190- valid : false ,
191- error : `Cannot run from block inside parallel: ${ parentParallelId } ` ,
192- }
193- }
194-
195222 const sentinelStartId = resolveContainerToSentinelStart ( blockId , dag )
196223 if ( ! sentinelStartId || ! dag . nodes . has ( sentinelStartId ) ) {
197224 return {
@@ -203,19 +230,8 @@ export function validateRunFromBlock(
203230 }
204231
205232 if ( node ) {
206- if ( node . metadata . isLoopNode ) {
207- return {
208- valid : false ,
209- error : `Cannot run from block inside loop: ${ node . metadata . subflowId } ` ,
210- }
211- }
212-
213- if ( node . metadata . isParallelBranch ) {
214- return {
215- valid : false ,
216- error : `Cannot run from block inside parallel: ${ node . metadata . subflowId } ` ,
217- }
218- }
233+ if ( node . metadata . isLoopNode ) return insideSubflow ( node . metadata . subflowId , 'loop' )
234+ if ( node . metadata . isParallelBranch ) return insideSubflow ( node . metadata . subflowId , 'parallel' )
219235
220236 if ( node . metadata . isSentinel ) {
221237 return { valid : false , error : 'Cannot run from sentinel node' }
@@ -236,7 +252,8 @@ export function validateRunFromBlock(
236252 if ( ! executedBlocks . has ( sourceId ) ) {
237253 return {
238254 valid : false ,
239- error : `Upstream dependency not executed: ${ sourceId } ` ,
255+ error : `Upstream dependency not executed: ${ label ( sourceId ) } ` ,
256+ refusal : { kind : 'upstream_not_executed' , blockId, upstreamBlockId : sourceId } ,
240257 }
241258 }
242259 }
@@ -245,6 +262,33 @@ export function validateRunFromBlock(
245262 return { valid : true }
246263}
247264
265+ /**
266+ * The top-level loop or parallel a block sits in, however deeply nested. A nested container
267+ * is refused as a start too, so naming the immediate parent would point at another refusal.
268+ * Parallel children are matched by their workflow id, which the DAG only holds as branch clones.
269+ */
270+ function findOutermostContainer (
271+ blockId : string ,
272+ dag : DAG
273+ ) : { id : string ; type : 'loop' | 'parallel' } | undefined {
274+ let outermost : { id : string ; type : 'loop' | 'parallel' } | undefined
275+ const visited = new Set ( [ blockId ] )
276+ let current = blockId
277+ while ( true ) {
278+ const loopId = findParentLoop ( current , dag )
279+ const parallelId = loopId ? undefined : findParentParallel ( current , dag )
280+ const parent = loopId
281+ ? { id : loopId , type : 'loop' as const }
282+ : parallelId
283+ ? { id : parallelId , type : 'parallel' as const }
284+ : undefined
285+ if ( ! parent || visited . has ( parent . id ) ) return outermost
286+ visited . add ( parent . id )
287+ outermost = parent
288+ current = parent . id
289+ }
290+ }
291+
248292function findParentLoop ( blockId : string , dag : DAG ) : string | undefined {
249293 for ( const [ loopId , config ] of dag . loopConfigs ) {
250294 if ( loopId !== blockId && config . nodes ?. includes ( blockId ) ) {
0 commit comments