diff --git a/.changeset/huge-rabbits-travel.md b/.changeset/huge-rabbits-travel.md new file mode 100644 index 000000000..c4323b7b0 --- /dev/null +++ b/.changeset/huge-rabbits-travel.md @@ -0,0 +1,14 @@ +--- +"@workflow/world-postgres": patch +"@workflow/world-local": patch +"@workflow/sveltekit": patch +"@workflow/builders": patch +"@workflow/nitro": patch +"@workflow/utils": patch +"@workflow/world": patch +"@workflow/core": patch +"@workflow/next": patch +"@workflow/web": patch +--- + +Added Control Flow Graph extraction from Workflows and extended manifest.json's schema to incorporate the graph structure into it. Refactored manifest generation to pass manifest as a parameter instead of using instance state. Add e2e tests for manifest validation across all builders. diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 984fa3f1c..4ab792cea 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -13,6 +13,7 @@ import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.j import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; import type { WorkflowConfig } from './types.js'; +import { extractWorkflowGraphs } from './workflows-extractor.js'; const enhancedResolve = promisify(enhancedResolveOriginal); @@ -270,6 +271,7 @@ export abstract class BaseBuilder { * Steps have full Node.js runtime access and handle side effects, API calls, etc. * * @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code + * @returns Build context (for watch mode) and the collected workflow manifest */ protected async createStepsBundle({ inputFiles, @@ -285,16 +287,17 @@ export abstract class BaseBuilder { outfile: string; format?: 'cjs' | 'esm'; externalizeNonSteps?: boolean; - }): Promise { + }): Promise<{ + context: esbuild.BuildContext | undefined; + manifest: WorkflowManifest; + }> { // These need to handle watching for dev to scan for // new entries and changes to existing ones - const { discoveredSteps: stepFiles } = await this.discoverEntries( - inputFiles, - dirname(outfile) - ); + const { discoveredSteps: stepFiles, discoveredWorkflows: workflowFiles } = + await this.discoverEntries(inputFiles, dirname(outfile)); // log the step files for debugging - await this.writeDebugFile(outfile, { stepFiles }); + await this.writeDebugFile(outfile, { stepFiles, workflowFiles }); const stepsBundleStart = Date.now(); const workflowManifest: WorkflowManifest = {}; @@ -316,7 +319,10 @@ export abstract class BaseBuilder { // Create a virtual entry that imports all files. All step definitions // will get registered thanks to the swc transform. - const imports = stepFiles + // We also import workflow files so their metadata is collected by the SWC plugin, + // even though they'll be externalized from the final bundle. + const filesToProcess = [...stepFiles, ...workflowFiles]; + const imports = filesToProcess .map((file) => { // Normalize both paths to forward slashes before calling relative() // This is critical on Windows where relative() can produce unexpected results with mixed path formats @@ -381,9 +387,14 @@ export abstract class BaseBuilder { plugins: [ createSwcPlugin({ mode: 'step', + // Include both step and workflow files so the SWC plugin processes them + // and collects metadata. Workflow files need to be included so their + // workflow metadata is collected, even though the workflow function + // bodies are left untouched in step mode. entriesToBundle: externalizeNonSteps ? [ ...stepFiles, + ...workflowFiles, ...(resolvedBuiltInSteps ? [resolvedBuiltInSteps] : []), ] : undefined, @@ -403,23 +414,14 @@ export abstract class BaseBuilder { this.logEsbuildMessages(stepsResult, 'steps bundle creation'); console.log('Created steps bundle', `${Date.now() - stepsBundleStart}ms`); - const partialWorkflowManifest = { - steps: workflowManifest.steps, - }; - // always write to debug file - await this.writeDebugFile( - join(dirname(outfile), 'manifest'), - partialWorkflowManifest, - true - ); - // Create .gitignore in .swc directory await this.createSwcGitignore(); if (this.config.watch) { - return esbuildCtx; + return { context: esbuildCtx, manifest: workflowManifest }; } await esbuildCtx.dispose(); + return { context: undefined, manifest: workflowManifest }; } /** @@ -539,16 +541,6 @@ export abstract class BaseBuilder { `${Date.now() - bundleStartTime}ms` ); - const partialWorkflowManifest = { - workflows: workflowManifest.workflows, - }; - - await this.writeDebugFile( - join(dirname(outfile), 'manifest'), - partialWorkflowManifest, - true - ); - if (this.config.workflowManifestPath) { const resolvedPath = resolve( process.cwd(), @@ -900,4 +892,107 @@ export const OPTIONS = handler;`; // We're intentionally silently ignoring this error - creating .gitignore isn't critical } } + + /** + * Creates a manifest JSON file containing step/workflow metadata + * and graph data for visualization. + */ + protected async createManifest({ + workflowBundlePath, + manifestDir, + manifest, + }: { + workflowBundlePath: string; + manifestDir: string; + manifest: WorkflowManifest; + }): Promise { + const buildStart = Date.now(); + console.log('Creating manifest...'); + + try { + const workflowGraphs = await extractWorkflowGraphs(workflowBundlePath); + + const steps = this.convertStepsManifest(manifest.steps); + const workflows = this.convertWorkflowsManifest( + manifest.workflows, + workflowGraphs + ); + + const output = { version: '1.0.0', steps, workflows }; + + await mkdir(manifestDir, { recursive: true }); + await writeFile( + join(manifestDir, 'manifest.json'), + JSON.stringify(output, null, 2) + ); + + const stepCount = Object.values(steps).reduce( + (acc, s) => acc + Object.keys(s).length, + 0 + ); + const workflowCount = Object.values(workflows).reduce( + (acc, w) => acc + Object.keys(w).length, + 0 + ); + + console.log( + `Created manifest with ${stepCount} step(s) and ${workflowCount} workflow(s)`, + `${Date.now() - buildStart}ms` + ); + } catch (error) { + console.warn( + 'Failed to create manifest:', + error instanceof Error ? error.message : String(error) + ); + } + } + + private convertStepsManifest( + steps: WorkflowManifest['steps'] + ): Record> { + const result: Record> = {}; + if (!steps) return result; + + for (const [filePath, entries] of Object.entries(steps)) { + result[filePath] = {}; + for (const [name, data] of Object.entries(entries)) { + result[filePath][name] = { stepId: data.stepId }; + } + } + return result; + } + + private convertWorkflowsManifest( + workflows: WorkflowManifest['workflows'], + graphs: Record< + string, + Record + > + ): Record< + string, + Record< + string, + { workflowId: string; graph: { nodes: any[]; edges: any[] } } + > + > { + const result: Record< + string, + Record< + string, + { workflowId: string; graph: { nodes: any[]; edges: any[] } } + > + > = {}; + if (!workflows) return result; + + for (const [filePath, entries] of Object.entries(workflows)) { + result[filePath] = {}; + for (const [name, data] of Object.entries(entries)) { + result[filePath][name] = { + workflowId: data.workflowId, + graph: graphs[filePath]?.[name]?.graph || { nodes: [], edges: [] }, + }; + } + } + return result; + } } diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index 1785f5912..eb11c0454 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -18,10 +18,21 @@ export class StandaloneBuilder extends BaseBuilder { tsBaseUrl: tsConfig.baseUrl, tsPaths: tsConfig.paths, }; - await this.buildStepsBundle(options); + const manifest = await this.buildStepsBundle(options); await this.buildWorkflowsBundle(options); await this.buildWebhookFunction(); + // Build unified manifest from workflow bundle + const workflowBundlePath = this.resolvePath( + this.config.workflowsBundlePath + ); + const manifestDir = this.resolvePath('.well-known/workflow/v1'); + await this.createManifest({ + workflowBundlePath, + manifestDir, + manifest, + }); + await this.createClientLibrary(); } @@ -33,18 +44,20 @@ export class StandaloneBuilder extends BaseBuilder { inputFiles: string[]; tsBaseUrl?: string; tsPaths?: Record; - }): Promise { + }) { console.log('Creating steps bundle at', this.config.stepsBundlePath); const stepsBundlePath = this.resolvePath(this.config.stepsBundlePath); await this.ensureDirectory(stepsBundlePath); - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ outfile: stepsBundlePath, inputFiles, tsBaseUrl, tsPaths, }); + + return manifest; } private async buildWorkflowsBundle({ diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index be8869675..943e7492a 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -20,11 +20,19 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { tsBaseUrl: tsConfig.baseUrl, tsPaths: tsConfig.paths, }; - await this.buildStepsFunction(options); + const manifest = await this.buildStepsFunction(options); await this.buildWorkflowsFunction(options); await this.buildWebhookFunction(options); await this.createBuildOutputConfig(outputDir); + // Generate unified manifest + const workflowBundlePath = join(workflowGeneratedDir, 'flow.func/index.js'); + await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest, + }); + await this.createClientLibrary(); } @@ -38,13 +46,13 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { workflowGeneratedDir: string; tsBaseUrl?: string; tsPaths?: Record; - }): Promise { + }) { console.log('Creating Vercel Build Output API steps function'); const stepsFuncDir = join(workflowGeneratedDir, 'step.func'); await mkdir(stepsFuncDir, { recursive: true }); // Create steps bundle - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ inputFiles, outfile: join(stepsFuncDir, 'index.js'), tsBaseUrl, @@ -57,6 +65,8 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { shouldAddSourcemapSupport: true, experimentalTriggers: [STEP_QUEUE_TRIGGER], }); + + return manifest; } private async buildWorkflowsFunction({ diff --git a/packages/builders/src/workflows-extractor.ts b/packages/builders/src/workflows-extractor.ts new file mode 100644 index 000000000..b0dbf7da1 --- /dev/null +++ b/packages/builders/src/workflows-extractor.ts @@ -0,0 +1,1779 @@ +import { readFile } from 'node:fs/promises'; +import type { + ArrowFunctionExpression, + BlockStatement, + CallExpression, + Expression, + FunctionDeclaration, + FunctionExpression, + Identifier, + MemberExpression, + Program, + Statement, + VariableDeclaration, +} from '@swc/core'; +import { parseSync } from '@swc/core'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Workflow primitives that should be shown as nodes in the graph. + * These are built-in workflow functions that represent meaningful + * pauses or wait points in the workflow execution. + */ +const WORKFLOW_PRIMITIVES = new Set(['sleep', 'createHook', 'createWebhook']); + +// ============================================================================ +// Internal Types (used during extraction only) +// ============================================================================ + +interface FunctionInfo { + name: string; + body: BlockStatement | Expression | null | undefined; + isStep: boolean; + stepId?: string; +} + +interface AnalysisContext { + parallelCounter: number; + loopCounter: number; + conditionalCounter: number; + nodeCounter: number; + inLoop: string | null; + inConditional: string | null; + /** Tracks variables assigned from createWebhook() or createHook() */ + webhookVariables: Set; +} + +interface AnalysisResult { + nodes: ManifestNode[]; + edges: ManifestEdge[]; + entryNodeIds: string[]; + exitNodeIds: string[]; +} + +/** + * Node metadata for control flow semantics + */ +export interface NodeMetadata { + loopId?: string; + loopIsAwait?: boolean; + conditionalId?: string; + conditionalBranch?: 'Then' | 'Else'; + parallelGroupId?: string; + parallelMethod?: string; + /** Step is passed as a reference (callback/tool) rather than directly called */ + isStepReference?: boolean; + /** Context where the step reference was found (e.g., "tools.getWeather.execute") */ + referenceContext?: string; + /** This node is a tool step connected to a DurableAgent */ + isTool?: boolean; + /** The name of the tool (key in tools object) */ + toolName?: string; + /** This node represents a collection of tools (imported variable) */ + isToolsCollection?: boolean; + /** The variable name of the tools collection */ + toolsVariable?: string; +} + +/** + * Graph node for workflow visualization + */ +export interface ManifestNode { + id: string; + type: string; + data: { + label: string; + nodeKind: string; + stepId?: string; + }; + metadata?: NodeMetadata; +} + +/** + * Graph edge for workflow control flow + */ +export interface ManifestEdge { + id: string; + source: string; + target: string; + type: 'default' | 'loop' | 'conditional' | 'parallel' | 'tool'; + label?: string; +} + +/** + * Graph data for a single workflow + */ +export interface WorkflowGraphData { + nodes: ManifestNode[]; + edges: ManifestEdge[]; +} + +/** + * Step entry in the manifest + */ +export interface ManifestStepEntry { + stepId: string; +} + +/** + * Workflow entry in the manifest (includes graph data) + */ +export interface ManifestWorkflowEntry { + workflowId: string; + graph: WorkflowGraphData; +} + +/** + * Manifest structure - single source of truth for all workflow metadata + */ +export interface Manifest { + version: string; + steps: { + [filePath: string]: { + [stepName: string]: ManifestStepEntry; + }; + }; + workflows: { + [filePath: string]: { + [workflowName: string]: ManifestWorkflowEntry; + }; + }; +} + +// ============================================================================= +// Extraction Functions +// ============================================================================= + +/** + * Extracts workflow graphs from a bundled workflow file. + * Returns workflow entries organized by file path, ready for merging into Manifest. + */ +export async function extractWorkflowGraphs(bundlePath: string): Promise<{ + [filePath: string]: { + [workflowName: string]: ManifestWorkflowEntry; + }; +}> { + const bundleCode = await readFile(bundlePath, 'utf-8'); + + try { + let actualWorkflowCode = bundleCode; + + const bundleAst = parseSync(bundleCode, { + syntax: 'ecmascript', + target: 'es2022', + }); + + const workflowCodeValue = extractWorkflowCodeFromBundle(bundleAst); + if (workflowCodeValue) { + actualWorkflowCode = workflowCodeValue; + } + + const ast = parseSync(actualWorkflowCode, { + syntax: 'ecmascript', + target: 'es2022', + }); + + const stepDeclarations = extractStepDeclarations(actualWorkflowCode); + const functionMap = buildFunctionMap(ast, stepDeclarations); + const variableMap = buildVariableMap(ast); + + return extractWorkflows(ast, stepDeclarations, functionMap, variableMap); + } catch (error) { + console.error('Failed to extract workflow graphs from bundle:', error); + return {}; + } +} + +/** + * Extract the workflowCode string value from a parsed bundle AST + */ +function extractWorkflowCodeFromBundle(ast: Program): string | null { + for (const item of ast.body) { + if (item.type === 'VariableDeclaration') { + for (const decl of item.declarations) { + if ( + decl.id.type === 'Identifier' && + decl.id.value === 'workflowCode' && + decl.init + ) { + if (decl.init.type === 'TemplateLiteral') { + return decl.init.quasis.map((q) => q.cooked || q.raw).join(''); + } + if (decl.init.type === 'StringLiteral') { + return decl.init.value; + } + } + } + } + } + return null; +} + +/** + * Extract step declarations using regex for speed + */ +function extractStepDeclarations( + bundleCode: string +): Map { + const stepDeclarations = new Map(); + + const stepPattern = + /var (\w+) = globalThis\[Symbol\.for\("WORKFLOW_USE_STEP"\)\]\("([^"]+)"\)/g; + + const lines = bundleCode.split('\n'); + for (const line of lines) { + stepPattern.lastIndex = 0; + const match = stepPattern.exec(line); + if (match) { + const [, varName, stepId] = match; + stepDeclarations.set(varName, { stepId }); + } + } + + return stepDeclarations; +} + +/** + * Extract inline step declarations from within a function body. + * These are steps defined as variable declarations inside a workflow function. + * Pattern: var/const varName = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("stepId") + */ +function extractInlineStepDeclarations( + stmts: Statement[] +): Map { + const inlineSteps = new Map(); + + for (const stmt of stmts) { + if (stmt.type === 'VariableDeclaration') { + const varDecl = stmt as VariableDeclaration; + for (const decl of varDecl.declarations) { + if ( + decl.id.type === 'Identifier' && + decl.init?.type === 'CallExpression' + ) { + const callExpr = decl.init as CallExpression; + // Check for globalThis[Symbol.for("WORKFLOW_USE_STEP")]("stepId") pattern + if (callExpr.callee.type === 'MemberExpression') { + const member = callExpr.callee as MemberExpression; + // Check if object is globalThis + if ( + member.object.type === 'Identifier' && + (member.object as Identifier).value === 'globalThis' && + member.property.type === 'Computed' + ) { + // For computed member access globalThis[Symbol.for(...)], + // the property is a Computed type containing the expression + const computedExpr = (member.property as any).expression; + if (computedExpr?.type === 'CallExpression') { + const symbolCall = computedExpr as CallExpression; + // Check if it's Symbol.for("WORKFLOW_USE_STEP") + if (symbolCall.callee.type === 'MemberExpression') { + const symbolMember = symbolCall.callee as MemberExpression; + if ( + symbolMember.object.type === 'Identifier' && + (symbolMember.object as Identifier).value === 'Symbol' && + symbolMember.property.type === 'Identifier' && + (symbolMember.property as Identifier).value === 'for' && + symbolCall.arguments.length > 0 && + symbolCall.arguments[0].expression.type === + 'StringLiteral' && + (symbolCall.arguments[0].expression as any).value === + 'WORKFLOW_USE_STEP' + ) { + // Extract the stepId from the outer call arguments + if ( + callExpr.arguments.length > 0 && + callExpr.arguments[0].expression.type === 'StringLiteral' + ) { + const stepId = (callExpr.arguments[0].expression as any) + .value; + const varName = (decl.id as Identifier).value; + inlineSteps.set(varName, { stepId }); + } + } + } + } + } + } + } + } + } + } + + return inlineSteps; +} + +/** + * Build a map of all functions in the bundle for transitive step resolution + */ +function buildFunctionMap( + ast: Program, + stepDeclarations: Map +): Map { + const functionMap = new Map(); + + for (const item of ast.body) { + if (item.type === 'FunctionDeclaration') { + const func = item as FunctionDeclaration; + if (func.identifier) { + const name = func.identifier.value; + const isStep = stepDeclarations.has(name); + functionMap.set(name, { + name, + body: func.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } + } + + if (item.type === 'VariableDeclaration') { + const varDecl = item as VariableDeclaration; + for (const decl of varDecl.declarations) { + if (decl.id.type === 'Identifier' && decl.init) { + const name = decl.id.value; + const isStep = stepDeclarations.has(name); + + if (decl.init.type === 'FunctionExpression') { + const funcExpr = decl.init as FunctionExpression; + functionMap.set(name, { + name, + body: funcExpr.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } else if (decl.init.type === 'ArrowFunctionExpression') { + const arrowFunc = decl.init as ArrowFunctionExpression; + functionMap.set(name, { + name, + body: arrowFunc.body, + isStep, + stepId: isStep ? stepDeclarations.get(name)?.stepId : undefined, + }); + } + } + } + } + } + + return functionMap; +} + +/** + * Build a map of variable definitions (objects) for tool resolution + * This allows us to resolve tools objects to the actual tools object + */ +function buildVariableMap(ast: Program): Map { + const variableMap = new Map(); + + for (const item of ast.body) { + if (item.type === 'VariableDeclaration') { + const varDecl = item as VariableDeclaration; + for (const decl of varDecl.declarations) { + if ( + decl.type === 'VariableDeclarator' && + decl.id.type === 'Identifier' && + decl.init?.type === 'ObjectExpression' + ) { + variableMap.set(decl.id.value, decl.init); + } + } + } + } + + return variableMap; +} + +/** + * Extract workflows from AST + */ +function extractWorkflows( + ast: Program, + stepDeclarations: Map, + functionMap: Map, + variableMap: Map +): { + [filePath: string]: { + [workflowName: string]: ManifestWorkflowEntry; + }; +} { + const result: { + [filePath: string]: { + [workflowName: string]: ManifestWorkflowEntry; + }; + } = {}; + + for (const item of ast.body) { + if (item.type === 'FunctionDeclaration') { + const func = item as FunctionDeclaration; + if (!func.identifier) continue; + + const workflowName = func.identifier.value; + const workflowId = findWorkflowId(ast, workflowName); + if (!workflowId) continue; + + // Extract file path and actual workflow name from workflowId: "workflow//path/to/file.ts//functionName" + // The bundler may rename functions to avoid collisions (e.g. addTenWorkflow -> addTenWorkflow2), + // but the workflowId contains the original TypeScript function name. + const parts = workflowId.split('//'); + const filePath = parts.length > 1 ? parts[1] : 'unknown'; + const actualWorkflowName = parts.length > 2 ? parts[2] : workflowName; + + const graph = analyzeWorkflowFunction( + func, + workflowName, + stepDeclarations, + functionMap, + variableMap + ); + + if (!result[filePath]) { + result[filePath] = {}; + } + + result[filePath][actualWorkflowName] = { + workflowId, + graph, + }; + } + } + + return result; +} + +/** + * Find workflowId assignment for a function + */ +function findWorkflowId(ast: Program, functionName: string): string | null { + for (const item of ast.body) { + if (item.type === 'ExpressionStatement') { + const expr = item.expression; + if (expr.type === 'AssignmentExpression') { + const left = expr.left; + if (left.type === 'MemberExpression') { + const obj = left.object; + const prop = left.property; + if ( + obj.type === 'Identifier' && + obj.value === functionName && + prop.type === 'Identifier' && + prop.value === 'workflowId' + ) { + const right = expr.right; + if (right.type === 'StringLiteral') { + return right.value; + } + } + } + } + } + } + return null; +} + +/** + * Analyze a workflow function and build its graph + */ +function analyzeWorkflowFunction( + func: FunctionDeclaration, + workflowName: string, + stepDeclarations: Map, + functionMap: Map, + variableMap: Map +): WorkflowGraphData { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + + // Add start node + nodes.push({ + id: 'start', + type: 'workflowStart', + data: { + label: `Start: ${workflowName}`, + nodeKind: 'workflow_start', + }, + }); + + const context: AnalysisContext = { + parallelCounter: 0, + loopCounter: 0, + conditionalCounter: 0, + nodeCounter: 0, + inLoop: null, + inConditional: null, + webhookVariables: new Set(), + }; + + let prevExitIds = ['start']; + + if (func.body?.stmts) { + // Extract inline step declarations from the workflow body + // These are steps defined as variables inside the workflow function + const inlineSteps = extractInlineStepDeclarations(func.body.stmts); + + // Merge inline steps with global step declarations + const mergedStepDeclarations = new Map(stepDeclarations); + for (const [name, info] of inlineSteps) { + mergedStepDeclarations.set(name, info); + } + + for (const stmt of func.body.stmts) { + const result = analyzeStatement( + stmt, + mergedStepDeclarations, + context, + functionMap, + variableMap + ); + + nodes.push(...result.nodes); + edges.push(...result.edges); + + for (const prevId of prevExitIds) { + for (const entryId of result.entryNodeIds) { + const edgeId = `e_${prevId}_${entryId}`; + if (!edges.find((e) => e.id === edgeId)) { + const targetNode = result.nodes.find((n) => n.id === entryId); + // Only use 'parallel' type for parallel group connections + // Sequential connections (including to/from loops) should be 'default' + const edgeType = targetNode?.metadata?.parallelGroupId + ? 'parallel' + : 'default'; + edges.push({ + id: edgeId, + source: prevId, + target: entryId, + type: edgeType, + }); + } + } + } + + if (result.exitNodeIds.length > 0) { + prevExitIds = result.exitNodeIds; + } + } + } + + // Add end node + nodes.push({ + id: 'end', + type: 'workflowEnd', + data: { + label: 'Return', + nodeKind: 'workflow_end', + }, + }); + + for (const prevId of prevExitIds) { + edges.push({ + id: `e_${prevId}_end`, + source: prevId, + target: 'end', + type: 'default', + }); + } + + return { nodes, edges }; +} + +/** + * Analyze a statement and extract step calls with proper CFG structure + */ +function analyzeStatement( + stmt: Statement, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + variableMap: Map +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + let entryNodeIds: string[] = []; + let exitNodeIds: string[] = []; + + if (stmt.type === 'VariableDeclaration') { + const varDecl = stmt as VariableDeclaration; + for (const decl of varDecl.declarations) { + if (decl.init) { + // Track webhook/hook variable assignments: const webhook = createWebhook() + if ( + decl.id.type === 'Identifier' && + decl.init.type === 'CallExpression' && + (decl.init as CallExpression).callee.type === 'Identifier' + ) { + const funcName = ((decl.init as CallExpression).callee as Identifier) + .value; + if (funcName === 'createWebhook' || funcName === 'createHook') { + context.webhookVariables.add((decl.id as Identifier).value); + } + } + + const result = analyzeExpression( + decl.init, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = result.entryNodeIds; + } else { + for (const prevId of exitNodeIds) { + for (const entryId of result.entryNodeIds) { + edges.push({ + id: `e_${prevId}_${entryId}`, + source: prevId, + target: entryId, + type: 'default', + }); + } + } + } + exitNodeIds = result.exitNodeIds; + } + } + } + + if (stmt.type === 'ExpressionStatement') { + const result = analyzeExpression( + stmt.expression, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + entryNodeIds = result.entryNodeIds; + exitNodeIds = result.exitNodeIds; + } + + if (stmt.type === 'IfStatement') { + const savedConditional = context.inConditional; + const conditionalId = `cond_${context.conditionalCounter++}`; + context.inConditional = conditionalId; + + if (stmt.consequent.type === 'BlockStatement') { + const branchResult = analyzeBlock( + stmt.consequent.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Then'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } + exitNodeIds.push(...branchResult.exitNodeIds); + } else { + // Handle single-statement consequent (no braces) + const branchResult = analyzeStatement( + stmt.consequent, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Then'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } + exitNodeIds.push(...branchResult.exitNodeIds); + } + + if (stmt.alternate?.type === 'BlockStatement') { + const branchResult = analyzeBlock( + stmt.alternate.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Else'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } else { + entryNodeIds.push(...branchResult.entryNodeIds); + } + exitNodeIds.push(...branchResult.exitNodeIds); + } else if (stmt.alternate) { + // Handle single-statement alternate (no braces) or else-if + const branchResult = analyzeStatement( + stmt.alternate, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of branchResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.conditionalId = conditionalId; + node.metadata.conditionalBranch = 'Else'; + } + + nodes.push(...branchResult.nodes); + edges.push(...branchResult.edges); + if (entryNodeIds.length === 0) { + entryNodeIds = branchResult.entryNodeIds; + } else { + entryNodeIds.push(...branchResult.entryNodeIds); + } + exitNodeIds.push(...branchResult.exitNodeIds); + } + + context.inConditional = savedConditional; + } + + if (stmt.type === 'WhileStatement' || stmt.type === 'ForStatement') { + const loopId = `loop_${context.loopCounter++}`; + const savedLoop = context.inLoop; + context.inLoop = loopId; + + const body = + stmt.type === 'WhileStatement' ? stmt.body : (stmt as any).body; + if (body.type === 'BlockStatement') { + const loopResult = analyzeBlock( + body.stmts, + 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', + }); + } + } + } else { + // Handle single-statement body (no braces) + const loopResult = 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; + } + + if (stmt.type === 'ForOfStatement') { + const loopId = `loop_${context.loopCounter++}`; + const savedLoop = context.inLoop; + context.inLoop = loopId; + + const isAwait = (stmt as any).isAwait || (stmt as any).await; + const body = (stmt as any).body; + + if (body.type === 'BlockStatement') { + const loopResult = analyzeBlock( + body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + node.metadata.loopIsAwait = isAwait; + } + + 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', + }); + } + } + } else { + // Handle single-statement body (no braces) + const loopResult = analyzeStatement( + body, + stepDeclarations, + context, + functionMap, + variableMap + ); + + for (const node of loopResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.loopId = loopId; + node.metadata.loopIsAwait = isAwait; + } + + 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( + (stmt as BlockStatement).stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...blockResult.nodes); + edges.push(...blockResult.edges); + entryNodeIds = blockResult.entryNodeIds; + exitNodeIds = blockResult.exitNodeIds; + } + + if (stmt.type === 'ReturnStatement' && (stmt as any).argument) { + const result = analyzeExpression( + (stmt as any).argument, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...result.nodes); + edges.push(...result.edges); + entryNodeIds = result.entryNodeIds; + exitNodeIds = result.exitNodeIds; + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze a block of statements with proper sequential chaining + */ +function analyzeBlock( + stmts: Statement[], + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + variableMap: Map +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + let entryNodeIds: string[] = []; + let currentExitIds: string[] = []; + + for (const stmt of stmts) { + const result = analyzeStatement( + stmt, + stepDeclarations, + context, + functionMap, + variableMap + ); + + if (result.nodes.length === 0) continue; + + nodes.push(...result.nodes); + edges.push(...result.edges); + + if (entryNodeIds.length === 0 && result.entryNodeIds.length > 0) { + entryNodeIds = result.entryNodeIds; + } + + if (currentExitIds.length > 0 && result.entryNodeIds.length > 0) { + for (const prevId of currentExitIds) { + for (const entryId of result.entryNodeIds) { + const targetNode = result.nodes.find((n) => n.id === entryId); + const edgeType = targetNode?.metadata?.parallelGroupId + ? 'parallel' + : 'default'; + edges.push({ + id: `e_${prevId}_${entryId}`, + source: prevId, + target: entryId, + type: edgeType, + }); + } + } + } + + if (result.exitNodeIds.length > 0) { + currentExitIds = result.exitNodeIds; + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds: currentExitIds }; +} + +/** + * Analyze an expression and extract step calls + */ +function analyzeExpression( + expr: Expression, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + variableMap: Map, + visitedFunctions: Set = new Set() +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + if (expr.type === 'AwaitExpression') { + const awaitedExpr = expr.argument; + if (awaitedExpr.type === 'CallExpression') { + const callExpr = awaitedExpr as CallExpression; + + // Check for Promise.all/race/allSettled/any + if (callExpr.callee.type === 'MemberExpression') { + const member = callExpr.callee as MemberExpression; + if ( + member.object.type === 'Identifier' && + (member.object as Identifier).value === 'Promise' && + member.property.type === 'Identifier' + ) { + const method = (member.property as Identifier).value; + if (['all', 'race', 'allSettled', 'any'].includes(method)) { + const parallelId = `parallel_${context.parallelCounter++}`; + + if (callExpr.arguments.length > 0) { + const arg = callExpr.arguments[0].expression; + if (arg.type === 'ArrayExpression') { + for (const element of arg.elements) { + if (element?.expression) { + const elemResult = analyzeExpression( + element.expression, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + + for (const node of elemResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.parallelGroupId = parallelId; + node.metadata.parallelMethod = method; + if (context.inLoop) { + node.metadata.loopId = context.inLoop; + } + } + + nodes.push(...elemResult.nodes); + edges.push(...elemResult.edges); + entryNodeIds.push(...elemResult.entryNodeIds); + exitNodeIds.push(...elemResult.exitNodeIds); + } + } + } else { + // Handle non-array arguments like array.map(stepFn) + const argResult = analyzeExpression( + arg, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + + for (const node of argResult.nodes) { + if (!node.metadata) node.metadata = {}; + node.metadata.parallelGroupId = parallelId; + node.metadata.parallelMethod = method; + if (context.inLoop) { + node.metadata.loopId = context.inLoop; + } + } + + nodes.push(...argResult.nodes); + edges.push(...argResult.edges); + entryNodeIds.push(...argResult.entryNodeIds); + exitNodeIds.push(...argResult.exitNodeIds); + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + } + } + + // Regular call - check if it's a step, workflow primitive, or helper function + if (callExpr.callee.type === 'Identifier') { + const funcName = (callExpr.callee as Identifier).value; + const stepInfo = stepDeclarations.get(funcName); + + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: funcName, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } else if (WORKFLOW_PRIMITIVES.has(funcName)) { + // Handle workflow primitives like sleep + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: ManifestNode = { + id: nodeId, + type: 'primitive', + data: { + label: funcName, + nodeKind: 'primitive', + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } else { + const transitiveResult = analyzeTransitiveCall( + funcName, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + nodes.push(...transitiveResult.nodes); + edges.push(...transitiveResult.edges); + entryNodeIds.push(...transitiveResult.entryNodeIds); + exitNodeIds.push(...transitiveResult.exitNodeIds); + } + } + + // Also analyze the arguments of awaited calls for step references in objects + for (const arg of callExpr.arguments) { + if (arg.expression?.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + arg.expression, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + } + } + + // Handle await on a webhook/hook variable: await webhook + if (awaitedExpr.type === 'Identifier') { + const varName = (awaitedExpr as Identifier).value; + if (context.webhookVariables.has(varName)) { + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: ManifestNode = { + id: nodeId, + type: 'primitive', + data: { + label: 'awaitWebhook', + nodeKind: 'primitive', + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } + } + } + + // Non-awaited call expression + if (expr.type === 'CallExpression') { + const callExpr = expr as CallExpression; + if (callExpr.callee.type === 'Identifier') { + const funcName = (callExpr.callee as Identifier).value; + const stepInfo = stepDeclarations.get(funcName); + + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: funcName, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } else if (WORKFLOW_PRIMITIVES.has(funcName)) { + // Handle non-awaited workflow primitives like createHook, createWebhook + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + + const node: ManifestNode = { + id: nodeId, + type: 'primitive', + data: { + label: funcName, + nodeKind: 'primitive', + }, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + }; + + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } else { + const transitiveResult = analyzeTransitiveCall( + funcName, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + nodes.push(...transitiveResult.nodes); + edges.push(...transitiveResult.edges); + entryNodeIds.push(...transitiveResult.entryNodeIds); + exitNodeIds.push(...transitiveResult.exitNodeIds); + } + } + } + + // Check for step references in object literals + if (expr.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + expr, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + + // Check for step references and step calls in function call arguments + if (expr.type === 'CallExpression') { + const callExpr = expr as CallExpression; + for (const arg of callExpr.arguments) { + if (arg.expression) { + if (arg.expression.type === 'Identifier') { + const argName = (arg.expression as Identifier).value; + const stepInfo = stepDeclarations.get(argName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: `${argName} (ref)`, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: { + isStepReference: true, + referenceContext: 'function argument', + }, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } + } + // Handle step calls passed as arguments (e.g., map.set(key, stepCall())) + if (arg.expression.type === 'CallExpression') { + const argCallExpr = arg.expression as CallExpression; + if (argCallExpr.callee.type === 'Identifier') { + const funcName = (argCallExpr.callee as Identifier).value; + const stepInfo = stepDeclarations.get(funcName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: funcName, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: + Object.keys(metadata).length > 0 ? metadata : undefined, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } + } + } + if (arg.expression.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + arg.expression, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + } + } + } + + // Check for step references in 'new' expressions + if (expr.type === 'NewExpression') { + const newExpr = expr as any; + + // Check if this is a DurableAgent instantiation + const isDurableAgent = + newExpr.callee?.type === 'Identifier' && + newExpr.callee?.value === 'DurableAgent'; + + if (isDurableAgent && newExpr.arguments?.length > 0) { + // Create a node for the DurableAgent itself + const agentNodeId = `node_${context.nodeCounter++}`; + const agentNode: ManifestNode = { + id: agentNodeId, + type: 'agent', + data: { + label: 'DurableAgent', + nodeKind: 'agent', + }, + metadata: { + isStepReference: true, + referenceContext: 'DurableAgent', + }, + }; + nodes.push(agentNode); + entryNodeIds.push(agentNodeId); + + // Look for tools in the constructor options + const optionsArg = newExpr.arguments[0]?.expression; + if (optionsArg?.type === 'ObjectExpression') { + const toolsResult = analyzeDurableAgentTools( + optionsArg, + stepDeclarations, + context, + agentNodeId, + variableMap + ); + nodes.push(...toolsResult.nodes); + edges.push(...toolsResult.edges); + + // If we found tools, they are the exit nodes + if (toolsResult.exitNodeIds.length > 0) { + exitNodeIds.push(...toolsResult.exitNodeIds); + } else { + exitNodeIds.push(agentNodeId); + } + } else { + exitNodeIds.push(agentNodeId); + } + } else if (newExpr.arguments) { + for (const arg of newExpr.arguments) { + if (arg.expression?.type === 'ObjectExpression') { + const refResult = analyzeObjectForStepReferences( + arg.expression, + stepDeclarations, + context, + '' + ); + nodes.push(...refResult.nodes); + edges.push(...refResult.edges); + entryNodeIds.push(...refResult.entryNodeIds); + exitNodeIds.push(...refResult.exitNodeIds); + } + } + } + } + + // Handle AssignmentExpression - analyze the right-hand side + if (expr.type === 'AssignmentExpression') { + const assignExpr = expr as any; + if (assignExpr.right) { + const rightResult = analyzeExpression( + assignExpr.right, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + nodes.push(...rightResult.nodes); + edges.push(...rightResult.edges); + entryNodeIds.push(...rightResult.entryNodeIds); + exitNodeIds.push(...rightResult.exitNodeIds); + } + } + + // Handle MemberExpression calls like array.map(stepFn) where step is passed as callback + if (expr.type === 'CallExpression') { + const callExpr = expr as CallExpression; + if (callExpr.callee.type === 'MemberExpression') { + const member = callExpr.callee as MemberExpression; + // Check if this is a method call like .map(), .forEach(), .filter() etc. + if (member.property.type === 'Identifier') { + const methodName = (member.property as Identifier).value; + if ( + [ + 'map', + 'forEach', + 'filter', + 'find', + 'some', + 'every', + 'flatMap', + ].includes(methodName) + ) { + // Check if any argument is a step function reference + for (const arg of callExpr.arguments) { + if (arg.expression?.type === 'Identifier') { + const argName = (arg.expression as Identifier).value; + const stepInfo = stepDeclarations.get(argName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const metadata: NodeMetadata = {}; + if (context.inLoop) { + metadata.loopId = context.inLoop; + } + if (context.inConditional) { + metadata.conditionalId = context.inConditional; + } + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: argName, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: + Object.keys(metadata).length > 0 ? metadata : undefined, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } + } + } + } + } + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze DurableAgent tools property to extract tool nodes + */ +function analyzeDurableAgentTools( + optionsObj: any, + stepDeclarations: Map, + context: AnalysisContext, + agentNodeId: string, + variableMap: Map +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + if (!optionsObj.properties) + return { nodes, edges, entryNodeIds, exitNodeIds }; + + // Helper function to extract tools from an ObjectExpression + function extractToolsFromObject(toolsObj: any): void { + for (const toolProp of toolsObj.properties || []) { + if (toolProp.type !== 'KeyValueProperty') continue; + + let toolName = ''; + if (toolProp.key.type === 'Identifier') { + toolName = toolProp.key.value; + } + + if (!toolName) continue; + + // Look for execute property in the tool definition + if (toolProp.value.type === 'ObjectExpression') { + for (const innerProp of toolProp.value.properties || []) { + if (innerProp.type !== 'KeyValueProperty') continue; + + let innerKey = ''; + if (innerProp.key.type === 'Identifier') { + innerKey = innerProp.key.value; + } + + if (innerKey === 'execute' && innerProp.value.type === 'Identifier') { + const stepName = innerProp.value.value; + const stepInfo = stepDeclarations.get(stepName); + + const nodeId = `node_${context.nodeCounter++}`; + const node: ManifestNode = { + id: nodeId, + type: 'tool', + data: { + label: stepName, + nodeKind: 'tool', + stepId: stepInfo?.stepId, + }, + metadata: { + isTool: true, + toolName: toolName, + referenceContext: `tools.${toolName}.execute`, + }, + }; + nodes.push(node); + exitNodeIds.push(nodeId); + + // Connect agent to this tool with tool edge type + edges.push({ + id: `e_${agentNodeId}_${nodeId}`, + source: agentNodeId, + target: nodeId, + type: 'tool', + }); + } + } + } + } + } + + // Find the 'tools' property + for (const prop of optionsObj.properties) { + if (prop.type !== 'KeyValueProperty') continue; + + let keyName = ''; + if (prop.key.type === 'Identifier') { + keyName = prop.key.value; + } + + if (keyName !== 'tools') continue; + + // Handle inline tools object + if (prop.value.type === 'ObjectExpression') { + extractToolsFromObject(prop.value); + } + + // Handle tools as a variable reference - resolve it from variableMap + if (prop.value.type === 'Identifier') { + const toolsVarName = prop.value.value; + + // Try to resolve the variable from the variableMap (bundled code) + const resolvedToolsObj = variableMap.get(toolsVarName); + + if (resolvedToolsObj && resolvedToolsObj.type === 'ObjectExpression') { + // Successfully resolved - extract individual tools + extractToolsFromObject(resolvedToolsObj); + } else { + // Fallback: create a placeholder node if we can't resolve + const nodeId = `node_${context.nodeCounter++}`; + const node: ManifestNode = { + id: nodeId, + type: 'tool', + data: { + label: `${toolsVarName}`, + nodeKind: 'tool', + }, + metadata: { + isToolsCollection: true, + toolsVariable: toolsVarName, + referenceContext: `tools:${toolsVarName}`, + }, + }; + nodes.push(node); + exitNodeIds.push(nodeId); + + // Connect agent to tools with tool edge type + edges.push({ + id: `e_${agentNodeId}_${nodeId}`, + source: agentNodeId, + target: nodeId, + type: 'tool', + }); + } + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze an object expression for step references + */ +function analyzeObjectForStepReferences( + obj: any, + stepDeclarations: Map, + context: AnalysisContext, + path: string +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + if (!obj.properties) return { nodes, edges, entryNodeIds, exitNodeIds }; + + for (const prop of obj.properties) { + if (prop.type !== 'KeyValueProperty') continue; + + let keyName = ''; + if (prop.key.type === 'Identifier') { + keyName = prop.key.value; + } else if (prop.key.type === 'StringLiteral') { + keyName = prop.key.value; + } + + const currentPath = path ? `${path}.${keyName}` : keyName; + + if (prop.value.type === 'Identifier') { + const valueName = prop.value.value; + const stepInfo = stepDeclarations.get(valueName); + if (stepInfo) { + const nodeId = `node_${context.nodeCounter++}`; + const node: ManifestNode = { + id: nodeId, + type: 'step', + data: { + label: `${valueName} (tool)`, + nodeKind: 'step', + stepId: stepInfo.stepId, + }, + metadata: { + isStepReference: true, + referenceContext: currentPath, + }, + }; + nodes.push(node); + entryNodeIds.push(nodeId); + exitNodeIds.push(nodeId); + } + } + + if (prop.value.type === 'ObjectExpression') { + const nestedResult = analyzeObjectForStepReferences( + prop.value, + stepDeclarations, + context, + currentPath + ); + nodes.push(...nestedResult.nodes); + edges.push(...nestedResult.edges); + entryNodeIds.push(...nestedResult.entryNodeIds); + exitNodeIds.push(...nestedResult.exitNodeIds); + } + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} + +/** + * Analyze a transitive function call to find step calls within helper functions + */ +function analyzeTransitiveCall( + funcName: string, + stepDeclarations: Map, + context: AnalysisContext, + functionMap: Map, + variableMap: Map, + visitedFunctions: Set +): AnalysisResult { + const nodes: ManifestNode[] = []; + const edges: ManifestEdge[] = []; + const entryNodeIds: string[] = []; + const exitNodeIds: string[] = []; + + if (visitedFunctions.has(funcName)) { + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + + const funcInfo = functionMap.get(funcName); + if (!funcInfo || funcInfo.isStep) { + return { nodes, edges, entryNodeIds, exitNodeIds }; + } + + visitedFunctions.add(funcName); + + try { + if (funcInfo.body) { + if (funcInfo.body.type === 'BlockStatement') { + const bodyResult = analyzeBlock( + funcInfo.body.stmts, + stepDeclarations, + context, + functionMap, + variableMap + ); + nodes.push(...bodyResult.nodes); + edges.push(...bodyResult.edges); + entryNodeIds.push(...bodyResult.entryNodeIds); + exitNodeIds.push(...bodyResult.exitNodeIds); + } else { + const exprResult = analyzeExpression( + funcInfo.body, + stepDeclarations, + context, + functionMap, + variableMap, + visitedFunctions + ); + nodes.push(...exprResult.nodes); + edges.push(...exprResult.edges); + entryNodeIds.push(...exprResult.entryNodeIds); + exitNodeIds.push(...exprResult.exitNodeIds); + } + } + } finally { + visitedFunctions.delete(funcName); + } + + return { nodes, edges, entryNodeIds, exitNodeIds }; +} diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts index 89fb80c44..98ef89523 100644 --- a/packages/cli/src/lib/inspect/env.ts +++ b/packages/cli/src/lib/inspect/env.ts @@ -44,6 +44,7 @@ export const getEnvVars = (): Record => { WORKFLOW_LOCAL_UI: env.WORKFLOW_LOCAL_UI || '', PORT: env.PORT || '', WORKFLOW_LOCAL_DATA_DIR: env.WORKFLOW_LOCAL_DATA_DIR || '', + WORKFLOW_MANIFEST_PATH: env.WORKFLOW_MANIFEST_PATH || '', }; }; @@ -53,14 +54,14 @@ const possibleWorkflowDataPaths = [ 'workflow-data', ]; +const possibleManifestPaths = [ + 'app/.well-known/workflow/v1/manifest.json', + 'src/app/.well-known/workflow/v1/manifest.json', + '.well-known/workflow/v1/manifest.json', +]; + async function findWorkflowDataDir(cwd: string) { - const paths = [ - ...possibleWorkflowDataPaths, - // This will be the case for testing CLI/Web from the CLI/Web - // package folders directly - '../../workbench/nextjs-turbopack/.next/workflow-data', - ]; - for (const path of paths) { + for (const path of possibleWorkflowDataPaths) { const fullPath = join(cwd, path); if ( await access(fullPath) @@ -74,12 +75,30 @@ async function findWorkflowDataDir(cwd: string) { } } +async function findManifestPath(cwd: string) { + for (const path of possibleManifestPaths) { + const fullPath = join(cwd, path); + if ( + await access(fullPath) + .then(() => true) + .catch(() => false) + ) { + const absolutePath = resolve(fullPath); + logger.debug('Found workflow manifest:', absolutePath); + return absolutePath; + } + } +} + /** * Overwrites process.env variables related to local world configuration, * if relevant environment variables aren't set already. */ export const inferLocalWorldEnvVars = async () => { const envVars = getEnvVars(); + const cwd = getWorkflowConfig().workingDir; + let repoRoot: string | undefined; + if (!envVars.PORT) { logger.warn( 'PORT environment variable is not set, using default port 3000' @@ -88,35 +107,63 @@ export const inferLocalWorldEnvVars = async () => { writeEnvVars(envVars); } - // Paths to check, in order of preference + // Infer workflow data directory if (!envVars.WORKFLOW_LOCAL_DATA_DIR) { - const cwd = getWorkflowConfig().workingDir; - const localPath = await findWorkflowDataDir(cwd); if (localPath) { envVars.WORKFLOW_LOCAL_DATA_DIR = localPath; writeEnvVars(envVars); - return; - } + } else { + // As a fallback, find the repo root, and try to infer the data dir from there + repoRoot = await findRepoRoot(cwd, cwd); + if (repoRoot) { + const repoPath = await findWorkflowDataDir(repoRoot); + if (repoPath) { + envVars.WORKFLOW_LOCAL_DATA_DIR = repoPath; + writeEnvVars(envVars); + } + } - // As a fallback, find the repo root, and try to infer the data dir from there - const repoRoot = await findRepoRoot(cwd, cwd); - if (repoRoot) { - const repoPath = await findWorkflowDataDir(repoRoot); - if (repoPath) { - envVars.WORKFLOW_LOCAL_DATA_DIR = repoPath; - writeEnvVars(envVars); - return; + if (!envVars.WORKFLOW_LOCAL_DATA_DIR) { + logger.error( + 'No workflow data directory found. Have you run any workflows yet?' + ); + logger.warn( + `\nCheck whether your data is in any of:\n${possibleWorkflowDataPaths.map((p) => ` ${cwd}/${p}${repoRoot && repoRoot !== cwd ? `\n ${repoRoot}/${p}` : ''}`).join('\n')}\n` + ); + throw new Error('No workflow data directory found'); } } + } - logger.error( - 'No workflow data directory found. Have you run any workflows yet?' - ); - logger.warn( - `\nCheck whether your data is in any of:\n${possibleWorkflowDataPaths.map((p) => ` ${cwd}/${p}${repoRoot && repoRoot !== cwd ? `\n ${repoRoot}/${p}` : ''}`).join('\n')}\n` - ); - throw new Error('No workflow data directory found'); + // Infer workflow manifest path (for Graph tab in web UI) + if (!envVars.WORKFLOW_MANIFEST_PATH) { + const localManifest = await findManifestPath(cwd); + if (localManifest) { + envVars.WORKFLOW_MANIFEST_PATH = localManifest; + writeEnvVars(envVars); + logger.debug(`Found workflow manifest at: ${localManifest}`); + } else { + // As a fallback, find the repo root, and try to infer the manifest from there + if (!repoRoot) { + repoRoot = await findRepoRoot(cwd, cwd); + } + if (repoRoot) { + const repoManifest = await findManifestPath(repoRoot); + if (repoManifest) { + envVars.WORKFLOW_MANIFEST_PATH = repoManifest; + writeEnvVars(envVars); + logger.debug(`Found workflow manifest at: ${repoManifest}`); + } + } + + // It's okay if manifest is not found - the web UI will just show empty workflows + if (!envVars.WORKFLOW_MANIFEST_PATH) { + logger.debug( + 'No workflow manifest found. Workflows tab will be empty.' + ); + } + } } }; diff --git a/packages/cli/src/lib/inspect/web.ts b/packages/cli/src/lib/inspect/web.ts index 037d86c05..84b8c1c24 100644 --- a/packages/cli/src/lib/inspect/web.ts +++ b/packages/cli/src/lib/inspect/web.ts @@ -370,6 +370,7 @@ function envToQueryParams( WORKFLOW_VERCEL_TEAM: 'team', PORT: 'port', WORKFLOW_LOCAL_DATA_DIR: 'dataDir', + WORKFLOW_MANIFEST_PATH: 'manifestPath', }; for (const [envName, paramName] of Object.entries(envToQueryParamMappings)) { diff --git a/packages/core/e2e/bench.bench.ts b/packages/core/e2e/bench.bench.ts index 4c7d2ad99..ff4bca7a0 100644 --- a/packages/core/e2e/bench.bench.ts +++ b/packages/core/e2e/bench.bench.ts @@ -1,9 +1,9 @@ import { withResolvers } from '@workflow/utils'; +import fs from 'fs'; +import path from 'path'; import { bench, describe } from 'vitest'; import { dehydrateWorkflowArguments } from '../src/serialization'; import { getProtectionBypassHeaders } from './utils'; -import fs from 'fs'; -import path from 'path'; const deploymentUrl = process.env.DEPLOYMENT_URL; if (!deploymentUrl) { diff --git a/packages/core/e2e/dev.test.ts b/packages/core/e2e/dev.test.ts index 824c80793..1b389dd5a 100644 --- a/packages/core/e2e/dev.test.ts +++ b/packages/core/e2e/dev.test.ts @@ -1,5 +1,5 @@ -import fs from 'fs/promises'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; import { getWorkbenchAppPath } from './utils'; diff --git a/packages/core/e2e/manifest.test.ts b/packages/core/e2e/manifest.test.ts new file mode 100644 index 000000000..6069be293 --- /dev/null +++ b/packages/core/e2e/manifest.test.ts @@ -0,0 +1,310 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, test } from 'vitest'; +import { getWorkbenchAppPath } from './utils'; + +interface ManifestStep { + stepId: string; +} + +interface ManifestNode { + id: string; + type: string; + data: { + label: string; + nodeKind: string; + stepId?: string; + }; + metadata?: { + loopId?: string; + loopIsAwait?: boolean; + conditionalId?: string; + conditionalBranch?: 'Then' | 'Else'; + parallelGroupId?: string; + parallelMethod?: string; + }; +} + +interface ManifestWorkflow { + workflowId: string; + graph: { + nodes: ManifestNode[]; + edges: Array<{ + id: string; + source: string; + target: string; + type?: string; + }>; + }; +} + +interface Manifest { + version: string; + steps: Record>; + workflows: Record>; +} + +// Map project names to their manifest paths +const MANIFEST_PATHS: Record = { + 'nextjs-webpack': 'app/.well-known/workflow/v1/manifest.json', + 'nextjs-turbopack': 'app/.well-known/workflow/v1/manifest.json', + nitro: 'node_modules/.nitro/workflow/manifest.json', + vite: 'node_modules/.nitro/workflow/manifest.json', + sveltekit: 'src/routes/.well-known/workflow/v1/manifest.json', + nuxt: 'node_modules/.nitro/workflow/manifest.json', + hono: 'node_modules/.nitro/workflow/manifest.json', + express: 'node_modules/.nitro/workflow/manifest.json', +}; + +function validateSteps(steps: Manifest['steps']) { + expect(steps).toBeDefined(); + expect(typeof steps).toBe('object'); + + const stepFiles = Object.keys(steps); + expect(stepFiles.length).toBeGreaterThan(0); + + for (const filePath of stepFiles) { + // Skip internal builtins from packages/workflow/dist/internal/builtins.js + if (filePath.includes('builtins.js')) { + continue; + } + + const fileSteps = steps[filePath]; + for (const [stepName, stepData] of Object.entries(fileSteps)) { + expect(stepData.stepId).toBeDefined(); + expect(stepData.stepId).toContain('step//'); + expect(stepData.stepId).toContain(stepName); + } + } +} + +function validateWorkflowGraph(graph: ManifestWorkflow['graph']) { + expect(graph).toBeDefined(); + expect(graph.nodes).toBeDefined(); + expect(Array.isArray(graph.nodes)).toBe(true); + expect(graph.edges).toBeDefined(); + expect(Array.isArray(graph.edges)).toBe(true); + + for (const node of graph.nodes) { + expect(node.id).toBeDefined(); + expect(node.type).toBeDefined(); + expect(node.data).toBeDefined(); + expect(node.data.label).toBeDefined(); + expect(node.data.nodeKind).toBeDefined(); + } + + for (const edge of graph.edges) { + expect(edge.id).toBeDefined(); + expect(edge.source).toBeDefined(); + expect(edge.target).toBeDefined(); + } + + // Only check for start/end nodes if graph has nodes + // Some workflows without steps may have empty graphs + if (graph.nodes.length > 0) { + const nodeTypes = graph.nodes.map((n) => n.type); + expect(nodeTypes).toContain('workflowStart'); + expect(nodeTypes).toContain('workflowEnd'); + } +} + +function validateWorkflows(workflows: Manifest['workflows']) { + expect(workflows).toBeDefined(); + expect(typeof workflows).toBe('object'); + + const workflowFiles = Object.keys(workflows); + expect(workflowFiles.length).toBeGreaterThan(0); + + for (const filePath of workflowFiles) { + const fileWorkflows = workflows[filePath]; + for (const [workflowName, workflowData] of Object.entries(fileWorkflows)) { + expect(workflowData.workflowId).toBeDefined(); + expect(workflowData.workflowId).toContain('workflow//'); + expect(workflowData.workflowId).toContain(workflowName); + validateWorkflowGraph(workflowData.graph); + } + } +} + +/** + * Helper to safely read manifest, returns null if file doesn't exist + */ +async function tryReadManifest(project: string): Promise { + try { + const appPath = getWorkbenchAppPath(project); + const manifestPath = path.join(appPath, MANIFEST_PATHS[project]); + const manifestContent = await fs.readFile(manifestPath, 'utf8'); + return JSON.parse(manifestContent); + } catch { + return null; + } +} + +describe.each(Object.keys(MANIFEST_PATHS))('manifest generation', (project) => { + test( + `${project}: manifest.json exists and has valid structure`, + { timeout: 30_000 }, + async () => { + // Skip if we're targeting a specific app + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; // Skip if manifest doesn't exist + + expect(manifest.version).toBe('1.0.0'); + validateSteps(manifest.steps); + validateWorkflows(manifest.workflows); + } + ); +}); + +/** + * Helper to find a workflow by name in the manifest + */ +function findWorkflow( + manifest: Manifest, + workflowName: string +): ManifestWorkflow | undefined { + for (const fileWorkflows of Object.values(manifest.workflows)) { + if (workflowName in fileWorkflows) { + return fileWorkflows[workflowName]; + } + } + return undefined; +} + +/** + * Helper to get step nodes from a workflow graph + */ +function getStepNodes(graph: ManifestWorkflow['graph']): ManifestNode[] { + return graph.nodes.filter((n) => n.data.stepId); +} + +/** + * Tests for single-statement control flow extraction. + * These verify that steps inside if/while/for without braces are extracted. + * Tests are skipped if manifest doesn't exist or workflow isn't found. + */ +describe.each(Object.keys(MANIFEST_PATHS))( + 'single-statement control flow extraction', + (project) => { + test( + `${project}: single-statement if extracts steps with conditional metadata`, + { timeout: 30_000 }, + async () => { + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; // Skip if manifest doesn't exist + + const workflow = findWorkflow(manifest, 'single_statement_if'); + if (!workflow) return; // Skip if workflow not in this project + + const stepNodes = getStepNodes(workflow.graph); + + // Should have steps extracted (singleStmtStepA and singleStmtStepB) + expect(stepNodes.length).toBeGreaterThan(0); + + // Verify steps have stepId containing expected names + const stepIds = stepNodes.map((n) => n.data.stepId); + expect(stepIds.some((id) => id?.includes('singleStmtStepA'))).toBe( + true + ); + expect(stepIds.some((id) => id?.includes('singleStmtStepB'))).toBe( + true + ); + + // Verify conditional metadata is present + const conditionalNodes = stepNodes.filter( + (n) => n.metadata?.conditionalId + ); + expect(conditionalNodes.length).toBeGreaterThan(0); + + // Verify we have both Then and Else branches + const thenNodes = stepNodes.filter( + (n) => n.metadata?.conditionalBranch === 'Then' + ); + const elseNodes = stepNodes.filter( + (n) => n.metadata?.conditionalBranch === 'Else' + ); + expect(thenNodes.length).toBeGreaterThan(0); + expect(elseNodes.length).toBeGreaterThan(0); + } + ); + + test( + `${project}: single-statement while extracts steps with loop metadata`, + { timeout: 30_000 }, + async () => { + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; // Skip if manifest doesn't exist + + const workflow = findWorkflow(manifest, 'single_statement_while'); + if (!workflow) return; // Skip if workflow not in this project + + const stepNodes = getStepNodes(workflow.graph); + + // Should have step extracted (singleStmtStepA) + expect(stepNodes.length).toBeGreaterThan(0); + + const stepIds = stepNodes.map((n) => n.data.stepId); + expect(stepIds.some((id) => id?.includes('singleStmtStepA'))).toBe( + true + ); + + // Verify loop metadata is present + const loopNodes = stepNodes.filter((n) => n.metadata?.loopId); + expect(loopNodes.length).toBeGreaterThan(0); + + // Verify loop back-edges exist + const loopEdges = workflow.graph.edges.filter((e) => e.type === 'loop'); + expect(loopEdges.length).toBeGreaterThan(0); + } + ); + + test( + `${project}: single-statement for extracts steps with loop metadata`, + { timeout: 30_000 }, + async () => { + if (process.env.APP_NAME && project !== process.env.APP_NAME) { + return; + } + + const manifest = await tryReadManifest(project); + if (!manifest) return; // Skip if manifest doesn't exist + + const workflow = findWorkflow(manifest, 'single_statement_for'); + if (!workflow) return; // Skip if workflow not in this project + + const stepNodes = getStepNodes(workflow.graph); + + // Should have steps extracted (singleStmtStepB and singleStmtStepC) + expect(stepNodes.length).toBeGreaterThan(0); + + const stepIds = stepNodes.map((n) => n.data.stepId); + expect(stepIds.some((id) => id?.includes('singleStmtStepB'))).toBe( + true + ); + expect(stepIds.some((id) => id?.includes('singleStmtStepC'))).toBe( + true + ); + + // Verify loop metadata is present + const loopNodes = stepNodes.filter((n) => n.metadata?.loopId); + expect(loopNodes.length).toBeGreaterThan(0); + + // Verify loop back-edges exist + const loopEdges = workflow.graph.edges.filter((e) => e.type === 'loop'); + expect(loopEdges.length).toBeGreaterThan(0); + } + ); + } +); diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 660c0bfc8..5e92bdb10 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -43,9 +43,19 @@ export async function getNextBuilder() { tsPaths: tsConfig.paths, }; - const stepsBuildContext = await this.buildStepsFunction(options); + const { context: stepsBuildContext, manifest } = + await this.buildStepsFunction(options); const workflowsBundle = await this.buildWorkflowsFunction(options); await this.buildWebhookRoute({ workflowGeneratedDir }); + + // Write unified manifest to workflow generated directory + const workflowBundlePath = join(workflowGeneratedDir, 'flow/route.js'); + await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest, + }); + await this.writeFunctionsConfig(outputDir); if (this.config.watch) { @@ -60,6 +70,7 @@ export async function getNextBuilder() { let stepsCtx = stepsBuildContext; let workflowsCtx = workflowsBundle; + let currentManifest = manifest; const normalizePath = (pathname: string) => pathname.replace(/\\/g, '/'); @@ -151,13 +162,15 @@ export async function getNextBuilder() { options.inputFiles = newInputFiles; await stepsCtx.dispose(); - const newStepsCtx = await this.buildStepsFunction(options); + const { context: newStepsCtx, manifest: newManifest } = + await this.buildStepsFunction(options); if (!newStepsCtx) { throw new Error( 'Invariant: expected steps build context after rebuild' ); } stepsCtx = newStepsCtx; + currentManifest = newManifest; await workflowsCtx.interimBundleCtx.dispose(); const newWorkflowsCtx = await this.buildWorkflowsFunction(options); @@ -167,6 +180,21 @@ export async function getNextBuilder() { ); } workflowsCtx = newWorkflowsCtx; + + // Rebuild unified manifest + try { + const workflowBundlePath = join( + workflowGeneratedDir, + 'flow/route.js' + ); + await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest: currentManifest, + }); + } catch (error) { + console.error('Failed to rebuild manifest:', error); + } }; const logBuildMessages = ( @@ -221,6 +249,21 @@ export async function getNextBuilder() { 'Rebuilt workflow bundle', `${Date.now() - rebuiltWorkflowStart}ms` ); + + // Rebuild unified manifest + try { + const workflowBundlePath = join( + workflowGeneratedDir, + 'flow/route.js' + ); + await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest: currentManifest, + }); + } catch (error) { + console.error('Failed to rebuild manifest:', error); + } }; const isWatchableFile = (path: string) => diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 8b36d24d1..d9c95409a 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -56,7 +56,7 @@ export class LocalBuilder extends BaseBuilder { inputFiles, }); - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ outfile: join(this.#outDir, 'steps.mjs'), externalizeNonSteps: true, format: 'esm', @@ -69,5 +69,13 @@ export class LocalBuilder extends BaseBuilder { outfile: webhookRouteFile, bundle: false, }); + + // Generate manifest + const workflowBundlePath = join(this.#outDir, 'workflows.mjs'); + await this.createManifest({ + workflowBundlePath, + manifestDir: this.#outDir, + manifest, + }); } } diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 531c7547a..b96fc3b56 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -55,9 +55,17 @@ export class SvelteKitBuilder extends BaseBuilder { }; // Generate the three SvelteKit route handlers - await this.buildStepsRoute(options); + const manifest = await this.buildStepsRoute(options); await this.buildWorkflowsRoute(options); await this.buildWebhookRoute({ workflowGeneratedDir }); + + // Generate unified manifest + const workflowBundlePath = join(workflowGeneratedDir, 'flow/+server.js'); + await this.createManifest({ + workflowBundlePath, + manifestDir: workflowGeneratedDir, + manifest, + }); } private async buildStepsRoute({ @@ -75,7 +83,7 @@ export class SvelteKitBuilder extends BaseBuilder { const stepsRouteDir = join(workflowGeneratedDir, 'step'); await mkdir(stepsRouteDir, { recursive: true }); - await this.createStepsBundle({ + const { manifest } = await this.createStepsBundle({ format: 'esm', inputFiles, outfile: join(stepsRouteDir, '+server.js'), @@ -99,6 +107,7 @@ export const POST = async ({request}) => { ); await writeFile(stepsRouteFile, stepsRouteContent); + return manifest; } private async buildWorkflowsRoute({ diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 8ea44cf7c..f77b52919 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -9,13 +9,8 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { WorkflowsList } from '@/components/workflows-list'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; -import { - useHookIdState, - useSidebarState, - useTabState, - useWorkflowIdState, -} from '@/lib/url-state'; import { useWorkflowGraphManifest } from '@/lib/flow-graph/use-workflow-graph'; +import { useHookIdState, useSidebarState, useTabState } from '@/lib/url-state'; export default function Home() { const router = useRouter(); @@ -26,13 +21,12 @@ export default function Home() { const selectedHookId = sidebar === 'hook' && hookId ? hookId : undefined; - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged // Fetch workflow graph manifest - // const { - // manifest: graphManifest, - // loading: graphLoading, - // error: graphError, - // } = useWorkflowGraphManifest(config); + const { + manifest: graphManifest, + loading: graphLoading, + error: graphError, + } = useWorkflowGraphManifest(config); const handleRunClick = (runId: string, streamId?: string) => { if (!streamId) { @@ -57,8 +51,7 @@ export default function Home() { } }; - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged. - // const workflows = graphManifest ? Object.values(graphManifest.workflows) : []; + const workflows = graphManifest ? Object.values(graphManifest.workflows) : []; return (
@@ -66,8 +59,7 @@ export default function Home() { Runs Hooks - {/* TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged */} - {/* Workflows */} + Workflows - {/* TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged */} - {/* +
- */} + ); diff --git a/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx b/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx index 1d8462529..cc25f7893 100644 --- a/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx +++ b/packages/web/src/components/flow-graph/workflow-graph-execution-viewer.tsx @@ -2,11 +2,17 @@ import { Background, + BaseEdge, Controls, type Edge, + EdgeLabelRenderer, + type EdgeProps, + Handle, MarkerType, type Node, + type NodeProps, Panel, + Position, ReactFlow, useEdgesState, useNodesState, @@ -59,8 +65,130 @@ function mapToStatusBadgeStatus( return status as StatusBadgeStatus; } +// Custom Loop Node component with left-side handles for self-loop edge +function LoopNodeComponent({ data, selected }: NodeProps) { + const nodeData = data as { + label: React.ReactNode; + nodeKind: string; + isLoopNode?: boolean; + isAwaitLoop?: boolean; + nodeStyle?: React.CSSProperties; + className?: string; + }; + + return ( +
+ {/* Node content */} + {nodeData.label} + + {/* Main flow handles (top/bottom) */} + + + + {/* Left-side handles for self-loop edge */} + + +
+ ); +} + // Custom node components -const nodeTypes = {}; +const nodeTypes = { + loopNode: LoopNodeComponent, +}; + +// Custom self-loop edge that curves to the left of the node +function SelfLoopEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + label, + markerEnd, +}: EdgeProps) { + // Calculate the loop path that goes to the left of the node + const loopOffset = 50; // How far left the loop extends + const verticalGap = targetY - sourceY; + + // Create a path that exits left, curves around, and enters left + const path = ` + M ${sourceX} ${sourceY} + C ${sourceX - loopOffset} ${sourceY}, + ${sourceX - loopOffset} ${targetY}, + ${targetX} ${targetY} + `; + + // Label position (center-left of the loop) + const labelX = sourceX - loopOffset - 10; + const labelY = sourceY + verticalGap / 2; + + return ( + <> + + {label && ( + +
+ + {label} + +
+
+ )} + + ); +} + +// Custom edge types +const edgeTypes = { + selfLoop: SelfLoopEdge, +}; // Get node styling based on node kind and execution state function getNodeStyle(nodeKind: string, executions?: StepExecution[]) { @@ -84,6 +212,21 @@ function getNodeStyle(nodeKind: string, executions?: StepExecution[]) { background: 'rgba(148, 163, 184, 0.15)', // slate border: '#94a3b8', }; + } else if (nodeKind === 'primitive') { + baseColors = { + background: 'rgba(168, 85, 247, 0.15)', // purple + border: '#a855f7', + }; + } else if (nodeKind === 'agent') { + baseColors = { + background: 'rgba(236, 72, 153, 0.15)', // pink + border: '#ec4899', + }; + } else if (nodeKind === 'tool') { + baseColors = { + background: 'rgba(249, 115, 22, 0.15)', // orange + border: '#f97316', + }; } // If no execution data, show faded state @@ -151,7 +294,7 @@ function getNodeStyle(nodeKind: string, executions?: StepExecution[]) { } // Get node icon based on node kind -function getNodeIcon(nodeKind: string) { +function getNodeIcon(nodeKind: string, label?: string) { if (nodeKind === 'workflow_start') { return ( @@ -162,6 +305,42 @@ function getNodeIcon(nodeKind: string) { ); } + if (nodeKind === 'primitive') { + // Different icons for different primitives + if (label === 'sleep') { + return ( + + ⏱ + + ); + } + if (label === 'createHook' || label === 'createWebhook') { + return ( + + 🔗 + + ); + } + return ( + + ⚙ + + ); + } + if (nodeKind === 'agent') { + return ( + + 🤖 + + ); + } + if (nodeKind === 'tool') { + return ( + + 🔧 + + ); + } return ; } @@ -181,17 +360,6 @@ function renderNodeLabel( // Add CFG metadata badges const badges: React.ReactNode[] = []; - if (metadata?.loopId) { - badges.push( - - {metadata.loopIsAwait ? '⟳ await loop' : '⟳ loop'} - - ); - } - if (metadata?.conditionalId) { badges.push(
-
{getNodeIcon(nodeData.nodeKind)}
+
+ {getNodeIcon(nodeData.nodeKind, nodeData.label)} +
{nodeData.label} @@ -289,16 +459,36 @@ function renderNodeLabel( ); } +// Layout constants - increased spacing for clarity +const LAYOUT = { + NODE_WIDTH: 220, + NODE_HEIGHT: 100, + HORIZONTAL_SPACING: 280, + VERTICAL_SPACING: 320, // Increased to prevent loop container overlap + START_X: 250, + PARALLEL_GROUP_PADDING: 25, + LOOP_GROUP_PADDING: 50, +}; + // Convert nodes with execution overlay // Helper to calculate enhanced layout with control flow function calculateEnhancedLayout(workflow: WorkflowGraph): { nodes: GraphNode[]; + groupNodes: Array<{ + id: string; + type: 'group'; + position: { x: number; y: number }; + style: React.CSSProperties; + data: { label: string }; + }>; additionalEdges: Array<{ id: string; source: string; target: string; type: string; label?: string; + sourceHandle?: string; + targetHandle?: string; }>; } { // Clone nodes (positions are always provided by the manifest adapter) @@ -309,10 +499,22 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { target: string; type: string; label?: string; + sourceHandle?: string; + targetHandle?: string; + }> = []; + const groupNodes: Array<{ + id: string; + type: 'group'; + position: { x: number; y: number }; + style: React.CSSProperties; + data: { label: string }; }> = []; // Group nodes by their control flow context - const parallelGroups = new Map(); + const parallelGroups = new Map< + string, + { nodes: GraphNode[]; method?: string } + >(); const loopNodes = new Map(); const conditionalGroups = new Map< string, @@ -321,8 +523,11 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { for (const node of nodes) { if (node.metadata?.parallelGroupId) { - const group = parallelGroups.get(node.metadata.parallelGroupId) || []; - group.push(node); + const group = parallelGroups.get(node.metadata.parallelGroupId) || { + nodes: [], + method: node.metadata.parallelMethod, + }; + group.nodes.push(node); parallelGroups.set(node.metadata.parallelGroupId, group); } if (node.metadata?.loopId) { @@ -344,21 +549,52 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { } } - // Layout parallel nodes side-by-side - for (const [, groupNodes] of parallelGroups) { - if (groupNodes.length <= 1) continue; + // Layout parallel nodes side-by-side and create visual group containers + for (const [groupId, group] of parallelGroups) { + const groupNodes_ = group.nodes; + if (groupNodes_.length <= 1) continue; - const baseY = groupNodes[0].position.y; - const spacing = 300; // horizontal spacing - const totalWidth = (groupNodes.length - 1) * spacing; - const startX = 250 - totalWidth / 2; + const baseY = groupNodes_[0].position.y; + const spacing = LAYOUT.HORIZONTAL_SPACING; + const totalWidth = (groupNodes_.length - 1) * spacing; + const startX = LAYOUT.START_X - totalWidth / 2; - groupNodes.forEach((node, idx) => { + groupNodes_.forEach((node, idx) => { node.position = { x: startX + idx * spacing, y: baseY, }; }); + + // Always create visual group container for Promise.all/race groups + const minX = Math.min(...groupNodes_.map((n) => n.position.x)); + const maxX = Math.max(...groupNodes_.map((n) => n.position.x)); + const methodLabel = + group.method === 'all' + ? 'Promise.all' + : group.method === 'race' + ? 'Promise.race' + : group.method === 'allSettled' + ? 'Promise.allSettled' + : 'Parallel'; + + groupNodes.push({ + id: `group_${groupId}`, + type: 'group', + position: { + x: minX - LAYOUT.PARALLEL_GROUP_PADDING, + y: baseY - LAYOUT.PARALLEL_GROUP_PADDING, + }, + style: { + width: + maxX - minX + LAYOUT.NODE_WIDTH + LAYOUT.PARALLEL_GROUP_PADDING * 2, + height: LAYOUT.NODE_HEIGHT + LAYOUT.PARALLEL_GROUP_PADDING * 2, + backgroundColor: 'rgba(59, 130, 246, 0.08)', + border: '2px dashed rgba(59, 130, 246, 0.4)', + borderRadius: 12, + }, + data: { label: methodLabel }, + }); } // Layout conditional branches side-by-side @@ -379,83 +615,374 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { thenNodes.forEach((node, idx) => { node.position = { x: 100, - y: baseY + idx * 120, + y: baseY + idx * LAYOUT.VERTICAL_SPACING, }; }); elseNodes.forEach((node, idx) => { node.position = { x: 400, - y: baseY + idx * 120, + y: baseY + idx * LAYOUT.VERTICAL_SPACING, }; }); } } - // Add loop-back edges + // Create visual containers for loops for (const [loopId, loopNodeList] of loopNodes) { if (loopNodeList.length > 0) { - // Find first and last nodes in the loop - loopNodeList.sort((a, b) => { - const aNum = parseInt(a.id.replace('node_', '')) || 0; - const bNum = parseInt(b.id.replace('node_', '')) || 0; - return aNum - bNum; - }); - - const firstNode = loopNodeList[0]; - const lastNode = loopNodeList[loopNodeList.length - 1]; + // Calculate bounding box for all loop nodes (include padding for nested Promise.all boxes) + const minX = Math.min(...loopNodeList.map((n) => n.position.x)); + const maxX = Math.max(...loopNodeList.map((n) => n.position.x)); + const minY = Math.min(...loopNodeList.map((n) => n.position.y)); + const maxY = Math.max(...loopNodeList.map((n) => n.position.y)); + + // Determine if this is an await loop + const isAwaitLoop = loopNodeList.some((n) => n.metadata?.loopIsAwait); + const loopLabel = isAwaitLoop ? '⟳ for await loop' : '⟳ loop'; + + // Check if this loop has nested parallel groups + const hasNestedParallel = loopNodeList.some( + (n) => n.metadata?.parallelGroupId + ); - // Add a back edge from last to first - // Note: no label needed - the nodes already show loop badges - additionalEdges.push({ - id: `loop_back_${loopId}`, - source: lastNode.id, - target: firstNode.id, - type: 'loop', + // Add extra padding for loop-back arrow and nested boxes (if any) + const loopBackPadding = 40; + const nestedBoxPadding = hasNestedParallel + ? LAYOUT.PARALLEL_GROUP_PADDING + 10 + : 10; + + // Create a visual group container for the loop + groupNodes.push({ + id: `loop_group_${loopId}`, + type: 'group', + position: { + x: + minX - + LAYOUT.LOOP_GROUP_PADDING - + loopBackPadding - + nestedBoxPadding, + y: minY - LAYOUT.LOOP_GROUP_PADDING - nestedBoxPadding - 25, // Space for label + }, + style: { + width: + maxX - + minX + + LAYOUT.NODE_WIDTH + + LAYOUT.LOOP_GROUP_PADDING * 2 + + loopBackPadding + + nestedBoxPadding * 2, + height: + maxY - + minY + + LAYOUT.NODE_HEIGHT + + LAYOUT.LOOP_GROUP_PADDING * 2 + + nestedBoxPadding * 2 + + 25, + backgroundColor: 'rgba(168, 85, 247, 0.06)', // Lighter purple for loops + border: '3px dashed rgba(168, 85, 247, 0.4)', + borderRadius: 24, + }, + data: { label: loopLabel }, }); + + // Add self-loop edges for each node in the loop (using left-side handles) + for (const loopNode of loopNodeList) { + additionalEdges.push({ + id: `self_loop_${loopNode.id}`, + source: loopNode.id, + target: loopNode.id, + sourceHandle: 'loop-out', + targetHandle: 'loop-in', + type: 'selfLoop', + label: isAwaitLoop ? '⟳ await' : '⟳ loop', + }); + } } } - return { nodes, additionalEdges }; + return { nodes, groupNodes, additionalEdges }; } function convertToReactFlowNodes( workflow: WorkflowGraph, execution?: WorkflowRunExecution ): Node[] { - const { nodes } = calculateEnhancedLayout(workflow); + const { nodes, groupNodes } = calculateEnhancedLayout(workflow); + + // Build a map of node id -> parent group id for quick lookup + const nodeToParent = new Map(); + const groupPositions = new Map(); - return nodes.map((node) => { + // Store group positions for relative position calculation + for (const group of groupNodes) { + groupPositions.set(group.id, group.position); + } + + // Determine which parallel groups are inside loop groups + const parallelGroupToLoop = new Map(); + for (const node of nodes) { + if (node.metadata?.parallelGroupId && node.metadata?.loopId) { + const parallelGroupId = `group_${node.metadata.parallelGroupId}`; + const loopGroupId = `loop_group_${node.metadata.loopId}`; + if (groupPositions.has(loopGroupId)) { + parallelGroupToLoop.set(parallelGroupId, loopGroupId); + } + } + } + + // Determine parent for each node + // If node is in a parallel group inside a loop, parent to the parallel group (which is itself in the loop) + // If node is only in a loop (not parallel), parent directly to the loop + // If node is only in a parallel group (not in loop), parent to the parallel group + for (const node of nodes) { + const parallelGroupId = node.metadata?.parallelGroupId + ? `group_${node.metadata.parallelGroupId}` + : null; + const loopGroupId = node.metadata?.loopId + ? `loop_group_${node.metadata.loopId}` + : null; + + if (parallelGroupId && groupPositions.has(parallelGroupId)) { + // If in a parallel group, always parent to it (parallel group handles its own loop parent) + nodeToParent.set(node.id, parallelGroupId); + } else if (loopGroupId && groupPositions.has(loopGroupId)) { + // Only in loop (no parallel group), parent directly to loop + nodeToParent.set(node.id, loopGroupId); + } + } + + // Start with group nodes (they render behind regular nodes) + // Process loop groups first, then parallel groups (so parallel groups can be children of loops) + const loopGroups = groupNodes.filter((g) => g.id.startsWith('loop_group_')); + const parallelGroups = groupNodes.filter((g) => g.id.startsWith('group_')); + + const reactFlowNodes: Node[] = []; + + // Add loop groups first (they are top-level) + for (const group of loopGroups) { + reactFlowNodes.push({ + id: group.id, + type: 'group', + position: group.position, + style: { + ...group.style, + cursor: 'grab', + }, + data: group.data, + selectable: true, + draggable: true, + }); + } + + // Add parallel groups (may be children of loop groups) + for (const group of parallelGroups) { + const parentLoopId = parallelGroupToLoop.get(group.id); + let position = group.position; + + if (parentLoopId) { + const parentPos = groupPositions.get(parentLoopId); + if (parentPos) { + // Convert to relative position within parent loop + position = { + x: group.position.x - parentPos.x, + y: group.position.y - parentPos.y, + }; + } + } + + reactFlowNodes.push({ + id: group.id, + type: 'group', + position, + parentId: parentLoopId, + extent: parentLoopId ? 'parent' : undefined, + style: { + ...group.style, + cursor: 'grab', + }, + data: group.data, + selectable: true, + draggable: true, + }); + } + + // Add regular nodes + for (const node of nodes) { const executions = execution?.nodeExecutions.get(node.id); const styles = getNodeStyle(node.data.nodeKind, executions); const isCurrentNode = execution?.currentNode === node.id; + const isLoopNode = !!node.metadata?.loopId; + const isAwaitLoop = !!node.metadata?.loopIsAwait; - let nodeType: 'input' | 'output' | 'default' = 'default'; + // Determine node type - use custom loopNode for nodes in loops + let nodeType: 'input' | 'output' | 'default' | 'loopNode' = isLoopNode + ? 'loopNode' + : 'default'; if (node.type === 'workflowStart') { nodeType = 'input'; } else if (node.type === 'workflowEnd') { nodeType = 'output'; } - return { - id: node.id, - type: nodeType, - position: node.position, - data: { - ...node.data, - label: renderNodeLabel(node.data, node.metadata, executions), - executions, // Store for onClick handler - }, - style: { - borderWidth: 1, - borderRadius: 8, - padding: 12, - width: 220, - ...styles, - }, - className: isCurrentNode ? 'animate-pulse-subtle' : '', - }; - }); + // Determine parent group and calculate relative position + const parentId = nodeToParent.get(node.id); + let position = node.position; + + if (parentId) { + const parentPos = groupPositions.get(parentId); + if (parentPos) { + // Convert to relative position within parent + position = { + x: node.position.x - parentPos.x, + y: node.position.y - parentPos.y, + }; + } + } + + // For loop nodes, pass style through data for custom component + if (isLoopNode) { + reactFlowNodes.push({ + id: node.id, + type: nodeType, + position, + parentId: parentId, + extent: parentId ? 'parent' : undefined, + expandParent: true, + data: { + ...node.data, + label: renderNodeLabel(node.data, node.metadata, executions), + executions, + isLoopNode: true, + isAwaitLoop, + nodeStyle: styles, + className: isCurrentNode ? 'animate-pulse-subtle' : '', + }, + }); + } else { + reactFlowNodes.push({ + id: node.id, + type: nodeType, + position, + parentId: parentId, + extent: parentId ? 'parent' : undefined, + expandParent: true, + data: { + ...node.data, + label: renderNodeLabel(node.data, node.metadata, executions), + executions, // Store for onClick handler + }, + style: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + width: 220, + ...styles, + }, + className: isCurrentNode ? 'animate-pulse-subtle' : '', + }); + } + } + + return reactFlowNodes; +} + +// Edge type with optional consolidation flag +type ConsolidatedEdge = { + id: string; + source: string; + target: string; + type?: string; + label?: string; + sourceHandle?: string; + targetHandle?: string; + isConsolidated?: boolean; + isOriginal?: boolean; +}; + +// Consolidate edges between parallel groups to reduce visual clutter +function consolidateEdges( + edges: ConsolidatedEdge[], + nodes: GraphNode[] +): ConsolidatedEdge[] { + // Build a map of node -> parallel group + const nodeToGroup = new Map(); + const groupNodes = new Map(); + + for (const node of nodes) { + const groupId = node.metadata?.parallelGroupId; + if (groupId) { + nodeToGroup.set(node.id, groupId); + const group = groupNodes.get(groupId) || []; + group.push(node); + groupNodes.set(groupId, group); + } + } + + // Find edges that connect different parallel groups (N×M pattern) + // Group edges by source-group → target-group + const groupToGroupEdges = new Map(); + const otherEdges: ConsolidatedEdge[] = []; + + for (const edge of edges) { + const sourceGroup = nodeToGroup.get(edge.source); + const targetGroup = nodeToGroup.get(edge.target); + + // If both nodes are in parallel groups AND they're different groups + if (sourceGroup && targetGroup && sourceGroup !== targetGroup) { + const key = `${sourceGroup}→${targetGroup}`; + const existing = groupToGroupEdges.get(key) || []; + existing.push(edge); + groupToGroupEdges.set(key, existing); + } else { + otherEdges.push(edge); + } + } + + // For each group-to-group connection, consolidate N×M edges to 1×M + // (one source to all targets, so each target has an incoming edge) + const consolidatedEdges: ConsolidatedEdge[] = [...otherEdges]; + + for (const [, groupEdges] of groupToGroupEdges) { + if (groupEdges.length > 1) { + // Find unique sources and targets + const sources = new Set(groupEdges.map((e) => e.source)); + const targets = new Set(groupEdges.map((e) => e.target)); + + // Pick the first source as representative + const representativeSource = [...sources][0]; + + // Keep one edge from the representative source to EACH target + // This ensures all target nodes have incoming edges + for (const target of targets) { + const edgeToTarget = groupEdges.find( + (e) => e.source === representativeSource && e.target === target + ); + if (edgeToTarget) { + consolidatedEdges.push({ + ...edgeToTarget, + isConsolidated: true, + }); + } else { + // If no direct edge exists, create one from the representative source + const anyEdgeToTarget = groupEdges.find((e) => e.target === target); + if (anyEdgeToTarget) { + consolidatedEdges.push({ + ...anyEdgeToTarget, + source: representativeSource, + id: `consolidated_${representativeSource}_${target}`, + isConsolidated: true, + }); + } + } + } + } else { + // Only one edge, keep as-is + consolidatedEdges.push(...groupEdges); + } + } + + return consolidatedEdges; } // Convert edges with execution overlay @@ -465,58 +992,118 @@ function convertToReactFlowEdges( ): Edge[] { const { additionalEdges } = calculateEnhancedLayout(workflow); - // Combine original edges with additional loop-back edges - const allEdges = [ - ...workflow.edges.map((e) => ({ ...e, isOriginal: true })), + // Transform original loop edges into loop_back_ edges (they go from exit nodes back to entry nodes) + // and keep all other edges as-is + const transformedOriginalEdges = workflow.edges.map((e) => { + if (e.type === 'loop') { + return { + ...e, + id: `loop_back_${e.source}_${e.target}`, + isOriginal: true, + }; + } + return { ...e, isOriginal: true }; + }); + + // Combine original edges with additional self-loop edges + const rawEdges = [ + ...transformedOriginalEdges, ...additionalEdges.map((e) => ({ ...e, isOriginal: false })), ]; + // Consolidate N×M edges between parallel groups into single edges + const allEdges = consolidateEdges(rawEdges, workflow.nodes); + return allEdges.map((edge) => { + // Handle self-loop edges specially (they use custom edge type and handles) + if (edge.type === 'selfLoop') { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: 'selfLoop', + label: edge.label, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 12, + height: 12, + color: '#a855f7', + }, + }; + } + const traversal = execution?.edgeTraversals.get(edge.id); const isTraversed = traversal && traversal.traversalCount > 0; + const hasExecution = !!execution; // Calculate average timing if available const avgTiming = traversal?.timings.length ? traversal.timings.reduce((a, b) => a + b, 0) / traversal.timings.length : undefined; - // Customize based on CFG edge type (but preserve execution state coloring) + // Determine edge type based on control flow + // Use bezier for main flow (cleaner curves), step for loops (clear back-flow) + let edgeType: 'bezier' | 'smoothstep' | 'step' = 'bezier'; let baseStrokeColor = '#94a3b8'; let strokeDasharray: string | undefined; let cfgLabel: string | undefined = edge.label; - let edgeType: 'smoothstep' | 'straight' | 'step' = 'smoothstep'; - - if (!isTraversed) { - // Only apply CFG styling if not executed (to keep execution state clear) - switch (edge.type) { - case 'parallel': - baseStrokeColor = '#3b82f6'; - strokeDasharray = '4,4'; - // No label needed - nodes have Promise.all/race/allSettled badges - cfgLabel = undefined; - break; - case 'loop': - baseStrokeColor = '#a855f7'; - strokeDasharray = '5,5'; - // Loop-back edges get a different path type for better visualization - if (edge.source === edge.target || !edge.isOriginal) { - edgeType = 'step'; - } - // No label needed - nodes have loop badges - cfgLabel = undefined; - break; - case 'conditional': - baseStrokeColor = '#f59e0b'; - strokeDasharray = '8,4'; - // No label needed - nodes have if/else badges - cfgLabel = undefined; - break; - } + + // Check if this is a loop-back edge (always show prominently) + const isLoopBackEdge = edge.id.startsWith('loop_back_'); + + switch (edge.type) { + case 'parallel': + // Parallel edges use straight paths for cleaner appearance + edgeType = 'smoothstep'; + baseStrokeColor = hasExecution ? '#cbd5e1' : '#3b82f6'; + strokeDasharray = hasExecution ? undefined : '4,4'; + cfgLabel = undefined; + break; + case 'loop': + // Loop-back edges route around nodes - always visible in purple + edgeType = 'step'; + baseStrokeColor = '#a855f7'; // Always purple for loop-back + strokeDasharray = '8,4'; + break; + case 'conditional': + edgeType = 'smoothstep'; + baseStrokeColor = hasExecution ? '#cbd5e1' : '#f59e0b'; + strokeDasharray = hasExecution ? undefined : '8,4'; + cfgLabel = undefined; + break; + default: + edgeType = 'bezier'; + baseStrokeColor = hasExecution ? '#cbd5e1' : '#94a3b8'; } - const finalStrokeColor = isTraversed ? '#22c55e' : baseStrokeColor; - const finalDasharray = - traversal && traversal.traversalCount > 1 ? '5,5' : strokeDasharray; + // Execution state overrides (but loop-back edges stay purple) + const finalStrokeColor = isLoopBackEdge + ? '#a855f7' + : isTraversed + ? '#22c55e' + : baseStrokeColor; + const finalDasharray = isTraversed + ? traversal && traversal.traversalCount > 1 + ? '5,5' + : undefined + : strokeDasharray; + + // Make non-traversed edges very subtle when there's execution data + // But loop-back edges are always visible + const opacity = isLoopBackEdge + ? 0.9 + : hasExecution && !isTraversed + ? 0.15 + : 1; + const strokeWidth = isLoopBackEdge + ? 2 + : isTraversed + ? 2.5 + : hasExecution + ? 0.5 + : 1; return { id: edge.id, @@ -540,23 +1127,28 @@ function convertToReactFlowEdges( cfgLabel ), labelStyle: { - fill: 'hsl(var(--foreground))', - fontWeight: 500, + fill: isLoopBackEdge ? '#a855f7' : 'hsl(var(--foreground))', + fontWeight: isLoopBackEdge ? 600 : 500, + fontSize: isLoopBackEdge ? '12px' : undefined, }, labelBgStyle: { fill: 'hsl(var(--background))', - fillOpacity: 0.8, + fillOpacity: 0.9, }, + labelBgPadding: isLoopBackEdge + ? ([6, 10] as [number, number]) + : ([4, 6] as [number, number]), + labelBgBorderRadius: isLoopBackEdge ? 6 : 4, markerEnd: { type: MarkerType.ArrowClosed, - width: 12, - height: 12, + width: isTraversed ? 14 : 10, + height: isTraversed ? 14 : 10, color: finalStrokeColor, }, style: { - strokeWidth: isTraversed ? 1.5 : 1, + strokeWidth, stroke: finalStrokeColor, - opacity: execution && !isTraversed ? 0.3 : 1, + opacity, strokeDasharray: finalDasharray, }, }; @@ -872,6 +1464,7 @@ export function WorkflowGraphExecutionViewer({ onEdgesChange={onEdgesChange} onNodeClick={handleNodeClick} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView minZoom={0.1} maxZoom={2} diff --git a/packages/web/src/components/flow-graph/workflow-graph-viewer.css b/packages/web/src/components/flow-graph/workflow-graph-viewer.css index e35190555..62db0cb3a 100644 --- a/packages/web/src/components/flow-graph/workflow-graph-viewer.css +++ b/packages/web/src/components/flow-graph/workflow-graph-viewer.css @@ -44,4 +44,39 @@ .animate-pulse-subtle { animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } + + /* Style for group nodes (parallel groups, loops) */ + .react-flow__node-group { + pointer-events: none; + z-index: -1 !important; + } + + /* Group labels positioned at top-left */ + .react-flow__node-group::before { + content: attr(data-label); + position: absolute; + top: 8px; + left: 12px; + font-size: 11px; + font-weight: 600; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + /* Make edges cleaner with rounded corners */ + .react-flow__edge-path { + stroke-linecap: round; + stroke-linejoin: round; + } + + /* Highlight executed path edges more prominently */ + .react-flow__edge.animated .react-flow__edge-path { + stroke-width: 2.5px; + } + + /* Style for loop-back edges - route them on the left side */ + .react-flow__edge[data-edge-type="loop"] .react-flow__edge-path { + stroke-dasharray: 6 4; + } \ No newline at end of file diff --git a/packages/web/src/components/flow-graph/workflow-graph-viewer.tsx b/packages/web/src/components/flow-graph/workflow-graph-viewer.tsx index 1408946dc..bfa159308 100644 --- a/packages/web/src/components/flow-graph/workflow-graph-viewer.tsx +++ b/packages/web/src/components/flow-graph/workflow-graph-viewer.tsx @@ -2,11 +2,17 @@ import { Background, + BaseEdge, Controls, type Edge, + EdgeLabelRenderer, + type EdgeProps, + Handle, MarkerType, type Node, + type NodeProps, Panel, + Position, ReactFlow, useEdgesState, useNodesState, @@ -24,8 +30,129 @@ interface WorkflowGraphViewerProps { workflow: WorkflowGraph; } +// Custom Loop Node component with left-side handles for self-loop edge +function LoopNodeComponent({ data, selected }: NodeProps) { + const nodeData = data as { + label: React.ReactNode; + nodeKind: string; + isLoopNode?: boolean; + isAwaitLoop?: boolean; + nodeStyle?: React.CSSProperties; + }; + + return ( +
+ {/* Node content */} + {nodeData.label} + + {/* Main flow handles (top/bottom) */} + + + + {/* Left-side handles for self-loop edge */} + + +
+ ); +} + // Custom node components -const nodeTypes = {}; +const nodeTypes = { + loopNode: LoopNodeComponent, +}; + +// Custom self-loop edge that curves to the left of the node +function SelfLoopEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + label, + markerEnd, +}: EdgeProps) { + // Calculate the loop path that goes to the left of the node + const loopOffset = 50; // How far left the loop extends + const verticalGap = targetY - sourceY; + + // Create a path that exits left, curves around, and enters left + const path = ` + M ${sourceX} ${sourceY} + C ${sourceX - loopOffset} ${sourceY}, + ${sourceX - loopOffset} ${targetY}, + ${targetX} ${targetY} + `; + + // Label position (center-left of the loop) + const labelX = sourceX - loopOffset - 10; + const labelY = sourceY + verticalGap / 2; + + return ( + <> + + {label && ( + +
+ + {label} + +
+
+ )} + + ); +} + +// Custom edge types +const edgeTypes = { + selfLoop: SelfLoopEdge, +}; // Get node styling based on node kind - uses theme-aware colors function getNodeStyle(nodeKind: string) { @@ -69,29 +196,61 @@ function getNodeIcon(nodeKind: string) { return ; } +// Layout constants +const LAYOUT = { + NODE_WIDTH: 220, + NODE_HEIGHT: 100, + HORIZONTAL_SPACING: 280, + VERTICAL_SPACING: 220, + START_X: 250, + PARALLEL_GROUP_PADDING: 25, + LOOP_GROUP_PADDING: 50, +}; + // Helper to calculate enhanced layout with control flow function calculateEnhancedLayout(workflow: WorkflowGraph): { nodes: GraphNode[]; + groupNodes: Array<{ + id: string; + type: 'group'; + position: { x: number; y: number }; + style: React.CSSProperties; + data: { label: string }; + }>; additionalEdges: Array<{ id: string; source: string; target: string; type: string; label?: string; + sourceHandle?: string; + targetHandle?: string; }>; } { // Clone nodes (positions are always provided by the manifest adapter) const nodes: GraphNode[] = workflow.nodes.map((node) => ({ ...node })); + const groupNodes: Array<{ + id: string; + type: 'group'; + position: { x: number; y: number }; + style: React.CSSProperties; + data: { label: string }; + }> = []; const additionalEdges: Array<{ id: string; source: string; target: string; type: string; label?: string; + sourceHandle?: string; + targetHandle?: string; }> = []; // Group nodes by their control flow context - const parallelGroups = new Map(); + const parallelGroups = new Map< + string, + { nodes: GraphNode[]; method?: string } + >(); const loopNodes = new Map(); const conditionalGroups = new Map< string, @@ -100,8 +259,11 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { for (const node of nodes) { if (node.metadata?.parallelGroupId) { - const group = parallelGroups.get(node.metadata.parallelGroupId) || []; - group.push(node); + const group = parallelGroups.get(node.metadata.parallelGroupId) || { + nodes: [], + method: node.metadata.parallelMethod, + }; + group.nodes.push(node); parallelGroups.set(node.metadata.parallelGroupId, group); } if (node.metadata?.loopId) { @@ -123,21 +285,52 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { } } - // Layout parallel nodes side-by-side - for (const [, groupNodes] of parallelGroups) { - if (groupNodes.length <= 1) continue; + // Layout parallel nodes side-by-side and create visual group containers + for (const [groupId, group] of parallelGroups) { + const groupNodes_ = group.nodes; + if (groupNodes_.length <= 1) continue; - const baseY = groupNodes[0].position.y; - const spacing = 300; // horizontal spacing - const totalWidth = (groupNodes.length - 1) * spacing; - const startX = 250 - totalWidth / 2; + const baseY = groupNodes_[0].position.y; + const spacing = LAYOUT.HORIZONTAL_SPACING; + const totalWidth = (groupNodes_.length - 1) * spacing; + const startX = LAYOUT.START_X - totalWidth / 2; - groupNodes.forEach((node, idx) => { + groupNodes_.forEach((node, idx) => { node.position = { x: startX + idx * spacing, y: baseY, }; }); + + // Create a visual group container + const minX = Math.min(...groupNodes_.map((n) => n.position.x)); + const maxX = Math.max(...groupNodes_.map((n) => n.position.x)); + const methodLabel = + group.method === 'all' + ? 'Promise.all' + : group.method === 'race' + ? 'Promise.race' + : group.method === 'allSettled' + ? 'Promise.allSettled' + : 'Parallel'; + + groupNodes.push({ + id: `group_${groupId}`, + type: 'group', + position: { + x: minX - LAYOUT.PARALLEL_GROUP_PADDING, + y: baseY - LAYOUT.PARALLEL_GROUP_PADDING, + }, + style: { + width: + maxX - minX + LAYOUT.NODE_WIDTH + LAYOUT.PARALLEL_GROUP_PADDING * 2, + height: LAYOUT.NODE_HEIGHT + LAYOUT.PARALLEL_GROUP_PADDING * 2, + backgroundColor: 'rgba(59, 130, 246, 0.05)', + border: '2px dashed rgba(59, 130, 246, 0.3)', + borderRadius: 12, + }, + data: { label: methodLabel }, + }); } // Layout conditional branches side-by-side @@ -171,63 +364,259 @@ function calculateEnhancedLayout(workflow: WorkflowGraph): { } } - // Add loop-back edges + // Create visual containers for loops for (const [loopId, loopNodeList] of loopNodes) { if (loopNodeList.length > 0) { - // Find first and last nodes in the loop - loopNodeList.sort((a, b) => { - const aNum = parseInt(a.id.replace('node_', '')) || 0; - const bNum = parseInt(b.id.replace('node_', '')) || 0; - return aNum - bNum; + // Calculate bounding box for all loop nodes + const minX = Math.min(...loopNodeList.map((n) => n.position.x)); + const maxX = Math.max(...loopNodeList.map((n) => n.position.x)); + const minY = Math.min(...loopNodeList.map((n) => n.position.y)); + const maxY = Math.max(...loopNodeList.map((n) => n.position.y)); + + const isAwaitLoop = loopNodeList.some((n) => n.metadata?.loopIsAwait); + const loopLabel = isAwaitLoop ? '⟳ for await' : '⟳ loop'; + + // Create a visual group container for the loop + groupNodes.push({ + id: `loop_group_${loopId}`, + type: 'group', + position: { + x: minX - LAYOUT.LOOP_GROUP_PADDING * 2, + y: minY - LAYOUT.LOOP_GROUP_PADDING - 25, + }, + style: { + width: + maxX - minX + LAYOUT.NODE_WIDTH + LAYOUT.LOOP_GROUP_PADDING * 3, + height: + maxY - + minY + + LAYOUT.NODE_HEIGHT + + LAYOUT.LOOP_GROUP_PADDING * 2 + + 25, + backgroundColor: 'rgba(168, 85, 247, 0.05)', + border: '3px dashed rgba(168, 85, 247, 0.4)', + borderRadius: 16, + }, + data: { label: loopLabel }, }); - const firstNode = loopNodeList[0]; - const lastNode = loopNodeList[loopNodeList.length - 1]; + // Add self-loop edges for each node in the loop (using left-side handles) + for (const loopNode of loopNodeList) { + additionalEdges.push({ + id: `self_loop_${loopNode.id}`, + source: loopNode.id, + target: loopNode.id, + sourceHandle: 'loop-out', + targetHandle: 'loop-in', + type: 'selfLoop', + label: isAwaitLoop ? '⟳ await' : '⟳ loop', + }); + } + } + } + + return { nodes, groupNodes, additionalEdges }; +} - // Add a back edge from last to first - // Note: no label needed - the nodes already show loop badges - additionalEdges.push({ - id: `loop_back_${loopId}`, - source: lastNode.id, - target: firstNode.id, - type: 'loop', - }); +// Edge type with optional consolidation flag +type ConsolidatedEdge = { + id: string; + source: string; + target: string; + type?: string; + label?: string; + sourceHandle?: string; + targetHandle?: string; + isConsolidated?: boolean; + isOriginal?: boolean; +}; + +// Consolidate edges between parallel groups to reduce visual clutter +function consolidateEdges( + edges: ConsolidatedEdge[], + nodes: GraphNode[] +): ConsolidatedEdge[] { + // Build a map of node -> parallel group + const nodeToGroup = new Map(); + for (const node of nodes) { + if (node.metadata?.parallelGroupId) { + nodeToGroup.set(node.id, node.metadata.parallelGroupId); + } + } + + // Find edges that connect different parallel groups (N×M pattern) + // Group edges by source-group → target-group + const groupToGroupEdges = new Map(); + const otherEdges: ConsolidatedEdge[] = []; + + for (const edge of edges) { + const sourceGroup = nodeToGroup.get(edge.source); + const targetGroup = nodeToGroup.get(edge.target); + + // Only consolidate if both nodes are in different parallel groups + if (sourceGroup && targetGroup && sourceGroup !== targetGroup) { + const key = `${sourceGroup}->${targetGroup}`; + const existing = groupToGroupEdges.get(key) || []; + existing.push(edge); + groupToGroupEdges.set(key, existing); + } else { + otherEdges.push(edge); + } + } + + // For each group-to-group connection, consolidate N×M edges to 1×M + const consolidatedEdges: ConsolidatedEdge[] = [...otherEdges]; + + for (const [, groupEdges] of groupToGroupEdges) { + if (groupEdges.length > 1) { + // Find unique targets + const uniqueTargets = [...new Set(groupEdges.map((e) => e.target))]; + // Pick the first source as the representative + const representativeSource = groupEdges[0].source; + + // Create one edge from representative source to each unique target + for (const target of uniqueTargets) { + const originalEdge = groupEdges.find((e) => e.target === target); + consolidatedEdges.push({ + ...originalEdge!, + id: `consolidated_${representativeSource}_${target}`, + source: representativeSource, + target, + isConsolidated: true, + }); + } + } else { + // Only one edge, keep as-is + consolidatedEdges.push(...groupEdges); } } - return { nodes, additionalEdges }; + return consolidatedEdges; } // Convert our graph nodes to React Flow format function convertToReactFlowNodes(workflow: WorkflowGraph): Node[] { - const { nodes } = calculateEnhancedLayout(workflow); + const { nodes, groupNodes } = calculateEnhancedLayout(workflow); + + // Build a map of node id -> parent group id for quick lookup + const nodeToParent = new Map(); + const groupPositions = new Map(); + + // Store group positions for relative position calculation + for (const group of groupNodes) { + groupPositions.set(group.id, group.position); + } + + // Determine which parallel groups are inside loop groups + const parallelGroupToLoop = new Map(); + for (const node of nodes) { + if (node.metadata?.parallelGroupId && node.metadata?.loopId) { + const parallelGroupId = `group_${node.metadata.parallelGroupId}`; + const loopGroupId = `loop_group_${node.metadata.loopId}`; + if (groupPositions.has(loopGroupId)) { + parallelGroupToLoop.set(parallelGroupId, loopGroupId); + } + } + } + + // Determine parent for each node + // If node is in a parallel group inside a loop, parent to the parallel group (which is itself in the loop) + // If node is only in a loop (not parallel), parent directly to the loop + // If node is only in a parallel group (not in loop), parent to the parallel group + for (const node of nodes) { + const parallelGroupId = node.metadata?.parallelGroupId + ? `group_${node.metadata.parallelGroupId}` + : null; + const loopGroupId = node.metadata?.loopId + ? `loop_group_${node.metadata.loopId}` + : null; + + if (parallelGroupId && groupPositions.has(parallelGroupId)) { + // If in a parallel group, always parent to it (parallel group handles its own loop parent) + nodeToParent.set(node.id, parallelGroupId); + } else if (loopGroupId && groupPositions.has(loopGroupId)) { + // Only in loop (no parallel group), parent directly to loop + nodeToParent.set(node.id, loopGroupId); + } + } + + // Start with group nodes (they render behind regular nodes) + // Process loop groups first, then parallel groups (so parallel groups can be children of loops) + const loopGroups = groupNodes.filter((g) => g.id.startsWith('loop_group_')); + const parallelGroups = groupNodes.filter((g) => g.id.startsWith('group_')); + + const reactFlowNodes: Node[] = []; - return nodes.map((node) => { + // Add loop groups first (they are top-level) + for (const group of loopGroups) { + reactFlowNodes.push({ + id: group.id, + type: 'group', + position: group.position, + style: { + ...group.style, + cursor: 'grab', + }, + data: group.data, + draggable: true, + selectable: true, + zIndex: -1, + }); + } + + // Add parallel groups (may be children of loop groups) + for (const group of parallelGroups) { + const parentLoopId = parallelGroupToLoop.get(group.id); + let position = group.position; + + if (parentLoopId) { + const parentPos = groupPositions.get(parentLoopId); + if (parentPos) { + // Convert to relative position within parent loop + position = { + x: group.position.x - parentPos.x, + y: group.position.y - parentPos.y, + }; + } + } + + reactFlowNodes.push({ + id: group.id, + type: 'group', + position, + parentId: parentLoopId, + extent: parentLoopId ? 'parent' : undefined, + style: { + ...group.style, + cursor: 'grab', + }, + data: group.data, + draggable: true, + selectable: true, + zIndex: -1, + }); + } + + // Add regular nodes + nodes.forEach((node) => { const styles = getNodeStyle(node.data.nodeKind); + const metadata = node.metadata; + const isLoopNode = !!metadata?.loopId; + const isAwaitLoop = !!metadata?.loopIsAwait; - // Determine node type based on its role in the workflow - let nodeType: 'input' | 'output' | 'default' = 'default'; + // Determine node type - use custom loopNode for nodes in loops + let nodeType: 'input' | 'output' | 'default' | 'loopNode' = isLoopNode + ? 'loopNode' + : 'default'; if (node.type === 'workflowStart') { nodeType = 'input'; // Only source handle (outputs edges) } else if (node.type === 'workflowEnd') { nodeType = 'output'; // Only target handle (receives edges) } - // Add CFG metadata badges - const metadata = node.metadata; + // Add CFG metadata badges (loop badge removed - now using visual notch) const badges: React.ReactNode[] = []; - if (metadata?.loopId) { - badges.push( - - {metadata.loopIsAwait ? '⟳ await loop' : '⟳ loop'} - - ); - } - if (metadata?.conditionalId) { badges.push( -
-
- {getNodeIcon(node.data.nodeKind)} -
- - {node.data.label} - -
- {badges.length > 0 && ( -
{badges}
- )} -
- ), - }, - style: { - borderWidth: 1, - borderRadius: 8, - padding: 12, - width: 220, - ...styles, - }, - }; + // Determine parent group and calculate relative position + const parentId = nodeToParent.get(node.id); + let position = node.position; + + if (parentId) { + const parentPos = groupPositions.get(parentId); + if (parentPos) { + // Convert to relative position within parent + position = { + x: node.position.x - parentPos.x, + y: node.position.y - parentPos.y, + }; + } + } + + const nodeLabel = ( +
+
+
{getNodeIcon(node.data.nodeKind)}
+ + {node.data.label} + +
+ {badges.length > 0 && ( +
{badges}
+ )} +
+ ); + + // For loop nodes, pass style through data for custom component + if (isLoopNode) { + reactFlowNodes.push({ + id: node.id, + type: nodeType, + position, + parentId: parentId, + extent: parentId ? 'parent' : undefined, + expandParent: true, + data: { + ...node.data, + label: nodeLabel, + isLoopNode: true, + isAwaitLoop, + nodeStyle: styles, + }, + }); + } else { + reactFlowNodes.push({ + id: node.id, + type: nodeType, + position, + parentId: parentId, + extent: parentId ? 'parent' : undefined, + expandParent: true, + data: { + ...node.data, + label: nodeLabel, + }, + style: { + borderWidth: 1, + borderRadius: 8, + padding: 12, + width: 220, + ...styles, + }, + }); + } }); + + return reactFlowNodes; } // Convert our graph edges to React Flow format function convertToReactFlowEdges(workflow: WorkflowGraph): Edge[] { const { additionalEdges } = calculateEnhancedLayout(workflow); - // Combine original edges with additional loop-back edges - const allEdges = [ - ...workflow.edges.map((e) => ({ ...e, isOriginal: true })), + // Transform original loop edges into loop_back_ edges (they go from exit nodes back to entry nodes) + // and keep all other edges as-is + const transformedOriginalEdges = workflow.edges.map((e) => { + if (e.type === 'loop') { + return { + ...e, + id: `loop_back_${e.source}_${e.target}`, + isOriginal: true, + }; + } + return { ...e, isOriginal: true }; + }); + + // Combine original edges with additional self-loop edges + const rawEdges = [ + ...transformedOriginalEdges, ...additionalEdges.map((e) => ({ ...e, isOriginal: false })), ]; + // Consolidate N×M edges between parallel groups into single edges + const allEdges = consolidateEdges(rawEdges, workflow.nodes); + return allEdges.map((edge) => { + // Handle self-loop edges specially (they use custom edge type and handles) + if (edge.type === 'selfLoop') { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: 'selfLoop', + label: edge.label, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 12, + height: 12, + color: '#a855f7', + }, + }; + } + + // Check if this is a loop-back edge + const isLoopBackEdge = edge.id.startsWith('loop_back_'); + // Customize edge style based on type let strokeColor = '#94a3b8'; // default gray let strokeWidth = 1; let strokeDasharray: string | undefined; const animated = false; let label: string | undefined = edge.label; - let edgeType: 'smoothstep' | 'straight' | 'step' = 'smoothstep'; + let edgeType: 'smoothstep' | 'straight' | 'step' | 'bezier' = 'bezier'; switch (edge.type) { case 'parallel': strokeColor = '#3b82f6'; // blue strokeWidth = 1.5; strokeDasharray = '4,4'; - // No label needed - nodes have Promise.all/race/allSettled badges + edgeType = 'smoothstep'; label = undefined; break; case 'loop': strokeColor = '#a855f7'; // purple - strokeWidth = 1.5; - strokeDasharray = '5,5'; - // Loop-back edges get a different path type for better visualization - if (edge.source === edge.target || !edge.isOriginal) { - edgeType = 'step'; - } - // No label needed - nodes have loop badges - label = undefined; + strokeWidth = 2; + strokeDasharray = '8,4'; + edgeType = 'step'; + // Keep label for loop-back edges break; case 'conditional': strokeColor = '#f59e0b'; // amber strokeWidth = 1; strokeDasharray = '8,4'; - // No label needed - nodes have if/else badges + edgeType = 'smoothstep'; label = undefined; break; default: - // Keep default styling + edgeType = 'bezier'; break; } @@ -348,10 +813,19 @@ function convertToReactFlowEdges(workflow: WorkflowGraph): Edge[] { type: edgeType, animated, label, - labelStyle: { fontSize: 12, fontWeight: 600 }, - labelBgPadding: [4, 2] as [number, number], - labelBgBorderRadius: 4, - labelBgStyle: { fill: strokeColor, fillOpacity: 0.15 }, + labelStyle: { + fill: isLoopBackEdge ? '#a855f7' : 'hsl(var(--foreground))', + fontWeight: isLoopBackEdge ? 600 : 500, + fontSize: isLoopBackEdge ? '12px' : '11px', + }, + labelBgPadding: isLoopBackEdge + ? ([6, 10] as [number, number]) + : ([4, 6] as [number, number]), + labelBgBorderRadius: isLoopBackEdge ? 6 : 4, + labelBgStyle: { + fill: 'hsl(var(--background))', + fillOpacity: 0.9, + }, markerEnd: { type: MarkerType.ArrowClosed, width: 12, @@ -394,6 +868,7 @@ export function WorkflowGraphViewer({ workflow }: WorkflowGraphViewerProps) { onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView minZoom={0.1} maxZoom={2} diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index c3d2e34b4..73caab1b7 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -10,7 +10,13 @@ import { type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; -import { AlertCircle, HelpCircle, List, Loader2 } from 'lucide-react'; +import { + AlertCircle, + GitBranch, + HelpCircle, + List, + Loader2, +} from 'lucide-react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useMemo, useState } from 'react'; @@ -42,10 +48,14 @@ import { } from '@/components/ui/tooltip'; import { buildUrlWithConfig, worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; +import { mapRunToExecution } from '@/lib/flow-graph/graph-execution-mapper'; +import { useWorkflowGraphManifest } from '@/lib/flow-graph/use-workflow-graph'; + import { CopyableText } from './display-utils/copyable-text'; import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; import { StatusBadge } from './display-utils/status-badge'; +import { WorkflowGraphExecutionViewer } from './flow-graph/workflow-graph-execution-viewer'; import { RunActionsButtons } from './run-actions'; import { Skeleton } from './ui/skeleton'; @@ -70,7 +80,8 @@ export function RunDetailView({ const env = useMemo(() => worldConfigToEnvMap(config), [config]); // Read tab and streamId from URL search params - const activeTab = (searchParams.get('tab') as 'trace' | 'streams') || 'trace'; + const activeTab = + (searchParams.get('tab') as 'trace' | 'graph' | 'streams') || 'trace'; const selectedStreamId = searchParams.get('streamId'); const showDebugActions = searchParams.get('debug') === '1'; @@ -91,9 +102,9 @@ export function RunDetailView({ ); const setActiveTab = useCallback( - (tab: 'trace' | 'streams') => { - // When switching to trace tab, clear streamId - if (tab === 'trace') { + (tab: 'trace' | 'graph' | 'streams') => { + // When switching to trace or graph tab, clear streamId + if (tab === 'trace' || tab === 'graph') { updateSearchParams({ tab, streamId: null }); } else { updateSearchParams({ tab }); @@ -118,11 +129,11 @@ export function RunDetailView({ ); // Fetch workflow graph manifest - // const { - // manifest: graphManifest, - // loading: graphLoading, - // error: graphError, - // } = useWorkflowGraphManifest(config); + const { + manifest: graphManifest, + loading: graphLoading, + error: graphError, + } = useWorkflowGraphManifest(config); // Fetch all run data with live updates const { @@ -147,24 +158,22 @@ export function RunDetailView({ // Find the workflow graph for this run // The manifest is keyed by workflowId which matches run.workflowName // e.g., "workflow//example/workflows/1_simple.ts//simple" - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged - // const workflowGraph = useMemo(() => { - // if (!graphManifest || !run.workflowName) return null; - // return graphManifest.workflows[run.workflowName] ?? null; - // }, [graphManifest, run.workflowName]); + const workflowGraph = useMemo(() => { + if (!graphManifest || !run.workflowName) return null; + return graphManifest.workflows[run.workflowName] ?? null; + }, [graphManifest, run.workflowName]); // Map run data to execution overlay - // TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged - // const execution = useMemo(() => { - // if (!workflowGraph || !run.runId) return null; - - // return mapRunToExecution( - // run, - // allSteps || [], - // allEvents || [], - // workflowGraph - // ); - // }, [workflowGraph, run, allSteps, allEvents]); + const execution = useMemo(() => { + if (!workflowGraph || !run.runId) return null; + + return mapRunToExecution( + run, + allSteps || [], + allEvents || [], + workflowGraph + ); + }, [workflowGraph, run, allSteps, allEvents]); const handleCancelClick = () => { setShowCancelDialog(true); @@ -450,7 +459,9 @@ export function RunDetailView({
setActiveTab(v as 'trace' | 'streams')} + onValueChange={(v) => + setActiveTab(v as 'trace' | 'graph' | 'streams') + } className="flex-1 flex flex-col min-h-0" > @@ -458,14 +469,14 @@ export function RunDetailView({ Trace + + + Graph + Streams - {/* - - Graph - */} @@ -571,7 +582,7 @@ export function RunDetailView({
- {/* +
{graphLoading ? (
@@ -608,7 +619,7 @@ export function RunDetailView({ /> )}
- */} + {auxiliaryDataLoading && ( @@ -622,3 +633,4 @@ export function RunDetailView({ ); } +// DCO remediation diff --git a/packages/web/src/components/settings-sidebar.tsx b/packages/web/src/components/settings-sidebar.tsx index c5ac69b24..9de50b407 100644 --- a/packages/web/src/components/settings-sidebar.tsx +++ b/packages/web/src/components/settings-sidebar.tsx @@ -224,8 +224,7 @@ export function SettingsSidebar({

- {/* TODO(Karthik): Uncomment after https://github.com/vercel/workflow/pull/455 is merged */} - {/*
+
-
*/} +
)} @@ -353,7 +352,6 @@ export function SettingsSidebar({ )} -