From bf150be15546df41abd42b99a67ac8bf42dbd7f7 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 27 Mar 2026 15:36:48 -0700 Subject: [PATCH 1/4] fix(builders): improve workflow extraction for complex workflows --- packages/builders/src/workflows-extractor.ts | 432 +++++++++++++++++++ 1 file changed, 432 insertions(+) diff --git a/packages/builders/src/workflows-extractor.ts b/packages/builders/src/workflows-extractor.ts index 4379ed1744..85020292ea 100644 --- a/packages/builders/src/workflows-extractor.ts +++ b/packages/builders/src/workflows-extractor.ts @@ -704,6 +704,27 @@ function containsAwaitExpression(node: any): boolean { return containsAwaitExpression(node.body); } + // Check try/catch/finally + if (node.type === 'TryStatement') { + return ( + containsAwaitExpression(node.block) || + containsAwaitExpression(node.handler?.body) || + containsAwaitExpression(node.finalizer) + ); + } + + // Check switch statement + if (node.type === 'SwitchStatement') { + return (node.cases || []).some((c: any) => + (c.consequent || []).some((s: any) => containsAwaitExpression(s)) + ); + } + + // Check do-while + if (node.type === 'DoWhileStatement') { + return containsAwaitExpression(node.body); + } + // Check assignment expressions (e.g., result = await doWork()) if (node.type === 'AssignmentExpression') { return containsAwaitExpression(node.right); @@ -1105,6 +1126,289 @@ function analyzeStatement( context.inLoop = savedLoop; } + // Handle TryStatement - recurse into try body and catch handler + if (stmt.type === 'TryStatement') { + const tryStmt = stmt as any; + + // Analyze the try block body + if (tryStmt.block?.stmts) { + const tryResult = analyzeBlock( + tryStmt.block.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + nodes.push(...tryResult.nodes); + edges.push(...tryResult.edges); + + if (entryNodeIds.length === 0) { + entryNodeIds = tryResult.entryNodeIds; + } + exitNodeIds = tryResult.exitNodeIds; + } + + // Analyze the catch handler if present + if (tryStmt.handler?.body?.stmts) { + const catchResult = analyzeBlock( + tryStmt.handler.body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + if (catchResult.nodes.length > 0) { + nodes.push(...catchResult.nodes); + edges.push(...catchResult.edges); + + // Connect the last try-body node to the catch entry as an error path + // If the try block had nodes, any of them could throw and reach catch + if (exitNodeIds.length > 0 && catchResult.entryNodeIds.length > 0) { + for (const tryExitId of exitNodeIds) { + for (const catchEntryId of catchResult.entryNodeIds) { + edges.push({ + id: `e_${tryExitId}_${catchEntryId}_catch`, + source: tryExitId, + target: catchEntryId, + type: 'conditional', + label: 'catch', + }); + } + } + } + + // If the try block had no nodes, the catch entry becomes the entry + if (entryNodeIds.length === 0) { + entryNodeIds = catchResult.entryNodeIds; + } + + // Both try exits and catch exits are valid exit points + exitNodeIds = [...exitNodeIds, ...catchResult.exitNodeIds]; + } + } + + // Analyze the finally block if present + if (tryStmt.finalizer?.stmts) { + const finallyResult = analyzeBlock( + tryStmt.finalizer.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + if (finallyResult.nodes.length > 0) { + nodes.push(...finallyResult.nodes); + edges.push(...finallyResult.edges); + + // Connect all previous exits to the finally entry + if (exitNodeIds.length > 0 && finallyResult.entryNodeIds.length > 0) { + for (const prevExitId of exitNodeIds) { + for (const finallyEntryId of finallyResult.entryNodeIds) { + edges.push({ + id: `e_${prevExitId}_${finallyEntryId}_finally`, + source: prevExitId, + target: finallyEntryId, + type: 'default', + }); + } + } + } + + if (entryNodeIds.length === 0) { + entryNodeIds = finallyResult.entryNodeIds; + } + exitNodeIds = finallyResult.exitNodeIds; + } + } + } + + // Handle SwitchStatement - each case is a branch + if (stmt.type === 'SwitchStatement') { + const switchStmt = stmt as any; + const cases: any[] = switchStmt.cases || []; + + // Check if any case has workflow-relevant nodes before creating the switch node + const caseResults: AnalysisResult[] = []; + let hasWorkflowNodes = false; + + for (const switchCase of cases) { + if (switchCase.consequent && switchCase.consequent.length > 0) { + const caseResult = analyzeBlock( + switchCase.consequent, + stepDeclarations, + context, + functionMap, + variableMap + ); + caseResults.push(caseResult); + if (caseResult.nodes.length > 0) { + hasWorkflowNodes = true; + } + } else { + caseResults.push({ + nodes: [], + edges: [], + entryNodeIds: [], + exitNodeIds: [], + }); + } + } + + if (hasWorkflowNodes) { + // Create a conditional node for the switch + const switchId = `cond_${context.conditionalCounter++}`; + const discriminantText = getConditionText(switchStmt.discriminant); + const switchNodeId = `${switchId}_node`; + + const switchNode: ManifestNode = { + id: switchNodeId, + type: 'conditional', + data: { + label: `switch(${discriminantText})`, + nodeKind: 'conditional', + }, + }; + nodes.push(switchNode); + entryNodeIds.push(switchNodeId); + + for (let i = 0; i < cases.length; i++) { + const switchCase = cases[i]; + const caseResult = caseResults[i]; + + if (caseResult.nodes.length > 0) { + const caseLabel = switchCase.test + ? getConditionText(switchCase.test) + : 'default'; + + for (const node of caseResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = switchId; + } + + nodes.push(...caseResult.nodes); + edges.push(...caseResult.edges); + + for (const caseEntryId of caseResult.entryNodeIds) { + edges.push({ + id: `e_${switchNodeId}_${caseEntryId}_case`, + source: switchNodeId, + target: caseEntryId, + type: 'conditional', + label: caseLabel, + }); + } + exitNodeIds.push(...caseResult.exitNodeIds); + } + } + } + } + + // Handle DoWhileStatement - same as while but loop-back is unconditional + if (stmt.type === 'DoWhileStatement') { + const body = (stmt as any).body; + const hasAwait = containsAwaitExpression(body); + const loopId = hasAwait ? `loop_${context.loopCounter++}` : undefined; + const savedLoop = context.inLoop; + if (loopId) { + context.inLoop = loopId; + } + + const loopResult = + body.type === 'BlockStatement' + ? analyzeBlock( + body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ) + : analyzeStatement( + body, + stepDeclarations, + context, + functionMap, + variableMap + ); + + if (loopId) { + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + } + } + + nodes.push(...loopResult.nodes); + edges.push(...loopResult.edges); + entryNodeIds = loopResult.entryNodeIds; + exitNodeIds = loopResult.exitNodeIds; + + if (loopId) { + for (const exitId of loopResult.exitNodeIds) { + for (const entryId of loopResult.entryNodeIds) { + edges.push({ + id: `e_${exitId}_back_${entryId}`, + source: exitId, + target: entryId, + type: 'loop', + }); + } + } + } + + context.inLoop = savedLoop; + } + + // Handle ForInStatement - same structure as ForOfStatement + if (stmt.type === 'ForInStatement') { + const loopId = `loop_${context.loopCounter++}`; + const savedLoop = context.inLoop; + context.inLoop = loopId; + + const body = (stmt as any).body; + const loopResult = + body.type === 'BlockStatement' + ? analyzeBlock( + body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ) + : analyzeStatement( + body, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + } + + nodes.push(...loopResult.nodes); + edges.push(...loopResult.edges); + entryNodeIds = loopResult.entryNodeIds; + exitNodeIds = loopResult.exitNodeIds; + + for (const exitId of loopResult.exitNodeIds) { + for (const entryId of loopResult.entryNodeIds) { + edges.push({ + id: `e_${exitId}_back_${entryId}`, + source: exitId, + target: entryId, + type: 'loop', + }); + } + } + + context.inLoop = savedLoop; + } + // Handle plain BlockStatement (bare blocks like { ... }) if (stmt.type === 'BlockStatement') { const blockResult = analyzeBlock( @@ -1134,6 +1438,23 @@ function analyzeStatement( exitNodeIds = result.exitNodeIds; } + // Fallback: for any unhandled statement type, attempt to recurse into + // known child properties to avoid silently dropping step calls. + // This handles LabeledStatement, WithStatement, ThrowStatement arguments, etc. + if (nodes.length === 0 && entryNodeIds.length === 0) { + const fallbackResult = analyzeStatementFallback( + stmt, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...fallbackResult.nodes); + edges.push(...fallbackResult.edges); + entryNodeIds = fallbackResult.entryNodeIds; + exitNodeIds = fallbackResult.exitNodeIds; + } + return { nodes, edges, entryNodeIds, exitNodeIds }; } @@ -1195,6 +1516,117 @@ function analyzeBlock( return { nodes, edges, entryNodeIds, exitNodeIds: currentExitIds }; } +/** + * Fallback analyzer for unhandled statement types. + * Recursively walks known child properties (body, expression, argument, etc.) + * to find step calls that would otherwise be silently dropped. + * Produces sequential edges between discovered nodes. + */ +function analyzeStatementFallback( + node: any, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + variableMap: Map +): AnalysisResult { + const results: AnalysisResult[] = []; + + // Check child statement properties + if (node.body) { + if (node.body.type === 'BlockStatement' && node.body.stmts) { + results.push( + analyzeBlock( + node.body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ) + ); + } else if (Array.isArray(node.body)) { + results.push( + analyzeBlock( + node.body, + stepDeclarations, + context, + functionMap, + variableMap + ) + ); + } else { + results.push( + analyzeStatement( + node.body, + stepDeclarations, + context, + functionMap, + variableMap + ) + ); + } + } + + // Check expression/argument properties (e.g., ThrowStatement argument) + if ( + node.argument && + typeof node.argument === 'object' && + node.argument.type + ) { + // If the argument is an expression, analyze it + if ( + !node.argument.type.endsWith('Statement') && + !node.argument.type.endsWith('Declaration') + ) { + const exprResult = analyzeExpression( + node.argument, + stepDeclarations, + context, + functionMap, + variableMap + ); + results.push(exprResult); + } + } + + // LabeledStatement has a .body that is a statement + // WithStatement has a .body + // These are handled by the node.body check above + + // Merge results sequentially + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + let entryNodeIds: string[] = []; + let exitNodeIds: string[] = []; + + for (const result of results) { + if (result.nodes.length === 0) continue; + + nodes.push(...result.nodes); + edges.push(...result.edges); + + if (entryNodeIds.length === 0) { + entryNodeIds = result.entryNodeIds; + } else if (exitNodeIds.length > 0 && result.entryNodeIds.length > 0) { + for (const prevId of exitNodeIds) { + for (const entryId of result.entryNodeIds) { + edges.push({ + id: `e_${prevId}_${entryId}`, + source: prevId, + target: entryId, + type: 'default', + }); + } + } + } + + if (result.exitNodeIds.length > 0) { + exitNodeIds = result.exitNodeIds; + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + /** * Analyze an expression and extract step calls */ From 7f78b465044a051ad96d181fb590c9e31126a78a Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 27 Mar 2026 15:44:16 -0700 Subject: [PATCH 2/4] add changeset --- .changeset/grumpy-candies-visit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/grumpy-candies-visit.md diff --git a/.changeset/grumpy-candies-visit.md b/.changeset/grumpy-candies-visit.md new file mode 100644 index 0000000000..5e5f11c3f1 --- /dev/null +++ b/.changeset/grumpy-candies-visit.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +improve workflow extraction for complex control flow From 5e5485a2749d11953556d8a6d7dfa48b5ad26dfd Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 27 Mar 2026 16:37:03 -0700 Subject: [PATCH 3/4] fix step extraction regex --- packages/builders/src/workflows-extractor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/workflows-extractor.ts b/packages/builders/src/workflows-extractor.ts index 85020292ea..0fac5f9629 100644 --- a/packages/builders/src/workflows-extractor.ts +++ b/packages/builders/src/workflows-extractor.ts @@ -298,7 +298,7 @@ function extractStepDeclarations( const stepDeclarations = new Map(); const stepPattern = - /var (\w+) = globalThis\[Symbol\.for\("WORKFLOW_USE_STEP"\)\]\("([^"]+)"\)/g; + /var (\w+) = globalThis\[(?:\/\*.*?\*\/\s*)?Symbol\.for\("WORKFLOW_USE_STEP"\)\]\("([^"]+)"\)/g; const lines = bundleCode.split('\n'); for (const line of lines) { From 1a79f580b02875162c5505d59e4bd0255d683951 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Fri, 27 Mar 2026 16:39:06 -0700 Subject: [PATCH 4/4] fix step extraction regex --- packages/builders/src/base-builder.ts | 33 ++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 2bc7cb0ff5..ced58eb3e5 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -1304,7 +1304,10 @@ export const OPTIONS = handler;`; workflows: WorkflowManifest['workflows'], graphs: Record< string, - Record + Record< + string, + { workflowId?: string; graph: { nodes: any[]; edges: any[] } } + > > ): Record< string, @@ -1328,7 +1331,10 @@ export const OPTIONS = handler;`; // Normalize by stripping leading "./" and file extensions. const normalizedGraphs = new Map< string, - Record + Record< + string, + { workflowId?: string; graph: { nodes: any[]; edges: any[] } } + > >(); for (const [graphPath, graphEntries] of Object.entries(graphs)) { const normalized = graphPath @@ -1337,6 +1343,25 @@ export const OPTIONS = handler;`; normalizedGraphs.set(normalized, graphEntries); } + // Build a workflowId-based lookup as a fallback for cases where the graph + // extractor path segment (e.g. "pkg@version") doesn't match the manifest + // file path (e.g. "dist/src/workflow.js"). This happens when workflowIds + // use package-scoped identifiers instead of file paths. + const graphByWorkflowId = new Map< + string, + { graph: { nodes: any[]; edges: any[] } } + >(); + for (const graphEntries of Object.values(graphs)) { + for (const [, entry] of Object.entries(graphEntries)) { + if (entry.workflowId) { + graphByWorkflowId.set( + entry.workflowId, + entry as { graph: { nodes: any[]; edges: any[] } } + ); + } + } + } + for (const [filePath, entries] of Object.entries(workflows)) { result[filePath] = {}; // Normalize the manifest file path for lookup @@ -1348,9 +1373,11 @@ export const OPTIONS = handler;`; graphs[filePath] || normalizedGraphs.get(normalizedFilePath); for (const [name, data] of Object.entries(entries)) { + const graphFromPath = graphEntries?.[name]?.graph; + const graphFromId = graphByWorkflowId.get(data.workflowId)?.graph; result[filePath][name] = { workflowId: data.workflowId, - graph: graphEntries?.[name]?.graph || { nodes: [], edges: [] }, + graph: graphFromPath || graphFromId || { nodes: [], edges: [] }, }; } }