88 BadRequestException ,
99} from '@nestjs/common' ;
1010import { status as grpcStatus , type ServiceError } from '@grpc/grpc-js' ;
11+ import { z } from 'zod' ;
1112
1213import { compileWorkflowGraph } from '../dsl/compiler' ;
1314// Ensure all worker components are registered before accessing the registry
@@ -21,6 +22,8 @@ import {
2122import {
2223 WorkflowGraphDto ,
2324 WorkflowGraphSchema ,
25+ WorkflowNodeSchema ,
26+ WorkflowNodeDataSchema ,
2427 ServiceWorkflowResponse ,
2528 UpdateWorkflowMetadataDto ,
2629} from './dto/workflow-graph.dto' ;
@@ -40,7 +43,7 @@ import {
4043 ExecutionInputPreview ,
4144 ExecutionTriggerMetadata ,
4245} from '@shipsec/shared' ;
43- import type { WorkflowRunRecord , WorkflowVersionRecord } from '../database/schema' ;
46+ import type { WorkflowRunRecord , WorkflowVersionRecord , WorkflowGraph } from '../database/schema' ;
4447import type { AuthContext } from '../auth/types' ;
4548
4649export interface WorkflowRunRequest {
@@ -366,13 +369,155 @@ export class WorkflowsService {
366369 record : WorkflowRecord ,
367370 version ?: WorkflowVersionRecord | null ,
368371 ) : ServiceWorkflowResponse {
372+ // Resolve dynamic ports for the graph so Entry Point nodes show correct outputs
373+ const resolvedGraph = this . resolveGraphPorts ( record . graph ) ;
374+
369375 return {
370376 ...record ,
377+ graph : resolvedGraph ,
371378 currentVersionId : version ?. id ?? null ,
372379 currentVersion : version ?. version ?? null ,
373380 } ;
374381 }
375382
383+ /**
384+ * Extract component parameters from node data, handling legacy schema formats.
385+ * This handles the migration from old formats where params might be at:
386+ * - nodeData.config.params (current schema)
387+ * - nodeData.parameters (legacy)
388+ * - nodeData.config (legacy - when config was the params object directly)
389+ */
390+ private extractNodeParams (
391+ nodeData : z . infer < typeof WorkflowNodeDataSchema > ,
392+ ) : Record < string , unknown > {
393+ // Current schema: params are in config.params
394+ if ( nodeData . config ?. params && Object . keys ( nodeData . config . params ) . length > 0 ) {
395+ return nodeData . config . params ;
396+ }
397+
398+ // Legacy: params stored directly on nodeData (via extended properties)
399+ const extendedNodeData = nodeData as Record < string , unknown > ;
400+ if ( extendedNodeData . parameters && typeof extendedNodeData . parameters === 'object' ) {
401+ return extendedNodeData . parameters as Record < string , unknown > ;
402+ }
403+
404+ // Legacy: config was the params object itself (before nested config.params structure)
405+ // Only use this if config doesn't have the modern structure
406+ if (
407+ nodeData . config &&
408+ ! ( 'params' in nodeData . config ) &&
409+ ! ( 'inputOverrides' in nodeData . config ) &&
410+ typeof nodeData . config === 'object'
411+ ) {
412+ return nodeData . config as Record < string , unknown > ;
413+ }
414+
415+ return { } ;
416+ }
417+
418+ /**
419+ * Extract component ID from node, handling frontend extensions.
420+ * The componentId might be in node.type or in extended nodeData properties.
421+ */
422+ private extractComponentId (
423+ node : z . infer < typeof WorkflowNodeSchema > ,
424+ nodeData : z . infer < typeof WorkflowNodeDataSchema > ,
425+ ) : string | null {
426+ // In backend schema, node.type contains the component ID
427+ if ( node . type && node . type !== 'workflow' ) {
428+ return node . type ;
429+ }
430+
431+ // Frontend extensions might store componentId/componentSlug in nodeData
432+ const extendedNodeData = nodeData as Record < string , unknown > ;
433+ if ( typeof extendedNodeData . componentId === 'string' ) {
434+ return extendedNodeData . componentId ;
435+ }
436+ if ( typeof extendedNodeData . componentSlug === 'string' ) {
437+ return extendedNodeData . componentSlug ;
438+ }
439+
440+ return null ;
441+ }
442+
443+ /**
444+ * Resolve dynamic ports for a single node based on its component and parameters.
445+ */
446+ private resolveNodePorts (
447+ node : z . infer < typeof WorkflowNodeSchema > ,
448+ ) : z . infer < typeof WorkflowNodeSchema > {
449+ const nodeData = node . data ;
450+ const componentId = this . extractComponentId ( node , nodeData ) ;
451+
452+ if ( ! componentId ) {
453+ return node ;
454+ }
455+
456+ try {
457+ const entry = componentRegistry . getMetadata ( componentId ) ;
458+ if ( ! entry ) {
459+ return node ;
460+ }
461+ const component = entry . definition ;
462+ const baseInputs = entry . inputs ?? extractPorts ( component . inputs ) ;
463+ const baseOutputs = entry . outputs ?? extractPorts ( component . outputs ) ;
464+
465+ const params = this . extractNodeParams ( nodeData ) ;
466+
467+ if ( typeof component . resolvePorts === 'function' ) {
468+ try {
469+ const resolved = component . resolvePorts ( params ) ;
470+ return {
471+ ...node ,
472+ data : {
473+ ...nodeData ,
474+ dynamicInputs : resolved . inputs ? extractPorts ( resolved . inputs ) : baseInputs ,
475+ dynamicOutputs : resolved . outputs ? extractPorts ( resolved . outputs ) : baseOutputs ,
476+ } ,
477+ } ;
478+ } catch ( resolveError ) {
479+ this . logger . warn ( `Failed to resolve ports for component ${ componentId } : ${ resolveError } ` ) ;
480+ return {
481+ ...node ,
482+ data : {
483+ ...nodeData ,
484+ dynamicInputs : baseInputs ,
485+ dynamicOutputs : baseOutputs ,
486+ } ,
487+ } ;
488+ }
489+ } else {
490+ return {
491+ ...node ,
492+ data : {
493+ ...nodeData ,
494+ dynamicInputs : baseInputs ,
495+ dynamicOutputs : baseOutputs ,
496+ } ,
497+ } ;
498+ }
499+ } catch ( error ) {
500+ this . logger . warn ( `Failed to get component ${ componentId } for port resolution: ${ error } ` ) ;
501+ return node ;
502+ }
503+ }
504+
505+ /**
506+ * Resolve dynamic ports for all nodes in a workflow graph.
507+ * This ensures Entry Point nodes and other components with resolvePorts
508+ * have their dynamicInputs/dynamicOutputs populated correctly.
509+ */
510+ private resolveGraphPorts ( graph : WorkflowGraph ) : WorkflowGraph {
511+ if ( ! graph || ! Array . isArray ( graph . nodes ) ) {
512+ return graph ;
513+ }
514+
515+ return {
516+ ...graph ,
517+ nodes : graph . nodes . map ( ( node ) => this . resolveNodePorts ( node ) ) ,
518+ } ;
519+ }
520+
376521 async delete ( id : string , auth ?: AuthContext | null ) : Promise < void > {
377522 const organizationId = await this . requireWorkflowAdmin ( id , auth ) ;
378523 await this . repository . delete ( id , { organizationId } ) ;
@@ -1213,80 +1358,11 @@ export class WorkflowsService {
12131358 return 0 ;
12141359 }
12151360
1216- private parse ( dto : WorkflowGraphDto ) {
1361+ private parse ( dto : WorkflowGraphDto ) : WorkflowGraph {
12171362 const parsed = WorkflowGraphSchema . parse ( dto ) ;
12181363
1219- // Resolve dynamic ports for each node based on its component and parameters
1220- const nodesWithResolvedPorts = parsed . nodes . map ( ( node ) => {
1221- const nodeData = node . data as any ;
1222- // Component ID can be in node.type, data.componentId, or data.componentSlug
1223- // In the workflow graph schema, node.type contains the component ID (e.g., "security.virustotal.lookup")
1224- const componentId =
1225- node . type !== 'workflow' ? node . type : nodeData . componentId || nodeData . componentSlug ;
1226-
1227- if ( ! componentId ) {
1228- return node ;
1229- }
1230-
1231- try {
1232- const entry = componentRegistry . getMetadata ( componentId ) ;
1233- if ( ! entry ) {
1234- return node ;
1235- }
1236- const component = entry . definition ;
1237- const baseInputs = entry . inputs ?? extractPorts ( component . inputs ) ;
1238- const baseOutputs = entry . outputs ?? extractPorts ( component . outputs ) ;
1239-
1240- // Get parameters from node data (they may be stored in config.params, parameters, or at data level)
1241- const params = nodeData . parameters || nodeData . config ?. params || nodeData . config || { } ;
1242-
1243- // Resolve ports using the component's resolvePorts function if available
1244- if ( typeof component . resolvePorts === 'function' ) {
1245- try {
1246- const resolved = component . resolvePorts ( params ) ;
1247- return {
1248- ...node ,
1249- data : {
1250- ...nodeData ,
1251- dynamicInputs : resolved . inputs ? extractPorts ( resolved . inputs ) : baseInputs ,
1252- dynamicOutputs : resolved . outputs ? extractPorts ( resolved . outputs ) : baseOutputs ,
1253- } ,
1254- } ;
1255- } catch ( resolveError ) {
1256- this . logger . warn (
1257- `Failed to resolve ports for component ${ componentId } : ${ resolveError } ` ,
1258- ) ;
1259- // Fall back to static metadata
1260- return {
1261- ...node ,
1262- data : {
1263- ...nodeData ,
1264- dynamicInputs : baseInputs ,
1265- dynamicOutputs : baseOutputs ,
1266- } ,
1267- } ;
1268- }
1269- } else {
1270- // No dynamic resolver, use static metadata
1271- return {
1272- ...node ,
1273- data : {
1274- ...nodeData ,
1275- dynamicInputs : baseInputs ,
1276- dynamicOutputs : baseOutputs ,
1277- } ,
1278- } ;
1279- }
1280- } catch ( error ) {
1281- this . logger . warn ( `Failed to get component ${ componentId } for port resolution: ${ error } ` ) ;
1282- return node ;
1283- }
1284- } ) ;
1285-
1286- return {
1287- ...parsed ,
1288- nodes : nodesWithResolvedPorts ,
1289- } ;
1364+ // Resolve dynamic ports for all nodes using the shared helper
1365+ return this . resolveGraphPorts ( parsed ) ;
12901366 }
12911367
12921368 private formatInputSummary ( inputs ?: Record < string , unknown > ) : string {
0 commit comments