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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/workflows/dto/workflow-graph.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export const WorkflowViewportSchema = z.object({
export const WorkflowNodeDataSchema = z.object({
label: z.string(),
config: z.record(z.string(), z.unknown()).default({}),
// Dynamic ports resolved from component's resolvePorts function
dynamicInputs: z.array(z.record(z.string(), z.unknown())).optional(),
dynamicOutputs: z.array(z.record(z.string(), z.unknown())).optional(),
});

export const WorkflowNodeSchema = z.object({
Expand Down
71 changes: 70 additions & 1 deletion backend/src/workflows/workflows.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nest
import { status as grpcStatus, type ServiceError } from '@grpc/grpc-js';

import { compileWorkflowGraph } from '../dsl/compiler';
// Ensure all worker components are registered before accessing the registry
import '@shipsec/studio-worker/components';
import { componentRegistry } from '@shipsec/component-sdk';
import { WorkflowDefinition } from '../dsl/types';
import {
TemporalService,
Expand Down Expand Up @@ -1137,7 +1140,73 @@ export class WorkflowsService {
}

private parse(dto: WorkflowGraphDto) {
return WorkflowGraphSchema.parse(dto);
const parsed = WorkflowGraphSchema.parse(dto);

// Resolve dynamic ports for each node based on its component and parameters
const nodesWithResolvedPorts = parsed.nodes.map((node) => {
const nodeData = node.data as any;
// Component ID can be in node.type, data.componentId, or data.componentSlug
// In the workflow graph schema, node.type contains the component ID (e.g., "security.virustotal.lookup")
const componentId = node.type !== 'workflow' ? node.type : (nodeData.componentId || nodeData.componentSlug);

if (!componentId) {
return node;
}

try {
const component = componentRegistry.get(componentId);
if (!component) {
return node;
}

// Get parameters from node data (they may be stored in config, parameters, or at data level)
const params = nodeData.parameters || nodeData.config || {};

// Resolve ports using the component's resolvePorts function if available
if (typeof component.resolvePorts === 'function') {
try {
const resolved = component.resolvePorts(params);
return {
...node,
data: {
...nodeData,
dynamicInputs: resolved.inputs ?? component.metadata?.inputs ?? [],
dynamicOutputs: resolved.outputs ?? component.metadata?.outputs ?? [],
},
};
} catch (resolveError) {
this.logger.warn(`Failed to resolve ports for component ${componentId}: ${resolveError}`);
// Fall back to static metadata
return {
...node,
data: {
...nodeData,
dynamicInputs: component.metadata?.inputs ?? [],
dynamicOutputs: component.metadata?.outputs ?? [],
},
};
}
} else {
// No dynamic resolver, use static metadata
return {
...node,
data: {
...nodeData,
dynamicInputs: component.metadata?.inputs ?? [],
dynamicOutputs: component.metadata?.outputs ?? [],
},
};
}
} catch (error) {
this.logger.warn(`Failed to get component ${componentId} for port resolution: ${error}`);
return node;
}
});

return {
...parsed,
nodes: nodesWithResolvedPorts,
};
}

private formatInputSummary(inputs?: Record<string, unknown>): string {
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/workflow/ScriptCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ export function ScriptCodeEditor({
return generateFullCode(inputVariables, outputVariables)
}, [inputVariables, outputVariables])

// Initialize code if empty or has old format
// Initialize code if completely empty - but don't overwrite existing code with different structure
useEffect(() => {
if (!code || code.trim() === '' || !hasValidStructure(code)) {
if (!code || code.trim() === '') {
onCodeChange(defaultCode)
}
}, []) // Only on mount
Expand All @@ -197,7 +197,7 @@ export function ScriptCodeEditor({
target: monaco.languages.typescript.ScriptTarget.ESNext,
module: monaco.languages.typescript.ModuleKind.ESNext,
allowNonTsExtensions: true,
lib: ['esnext'],
lib: ['esnext', 'dom'],
strict: true,
noImplicitAny: false,
})
Expand All @@ -206,7 +206,7 @@ export function ScriptCodeEditor({
target: monaco.languages.typescript.ScriptTarget.ESNext,
module: monaco.languages.typescript.ModuleKind.ESNext,
allowNonTsExtensions: true,
lib: ['esnext'],
lib: ['esnext', 'dom'],
})
}, [])

Expand Down
8 changes: 8 additions & 0 deletions frontend/src/utils/workflowSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ export function deserializeNodes(workflow: { graph: { nodes: BackendNode[], edge
// Extract UI metadata from config if present
const configWithPossibleUi = node.data.config || {}
const { __ui, ...cleanConfig } = configWithPossibleUi as { __ui?: any; [key: string]: any }

// Extract dynamic ports from backend node data (if present)
const backendNodeData = node.data as any
const dynamicInputs = backendNodeData.dynamicInputs
const dynamicOutputs = backendNodeData.dynamicOutputs

return {
id: node.id,
Expand All @@ -155,6 +160,9 @@ export function deserializeNodes(workflow: { graph: { nodes: BackendNode[], edge
parameters: cleanConfig, // Map config to parameters for frontend (without __ui)
status: 'idle', // Reset execution state
inputs: inputMappingsByNode.get(node.id) ?? {},
// Dynamic ports resolved by backend
...(dynamicInputs ? { dynamicInputs } : {}),
...(dynamicOutputs ? { dynamicOutputs } : {}),
// Restore UI metadata (like size for text blocks)
...__ui ? { ui: __ui } : {},
},
Expand Down
Loading