canvas-website/src/lib/workflow/validation.ts

467 lines
13 KiB
TypeScript

/**
* Port Validation
*
* Handles type compatibility checking between ports and validates
* workflow connections to prevent invalid data flow.
*/
import {
PortDataType,
InputPort,
OutputPort,
BlockDefinition,
PortBinding,
isTypeCompatible,
} from './types'
import { getBlockDefinition, hasBlockDefinition } from './blockRegistry'
// =============================================================================
// Validation Result Types
// =============================================================================
export interface ValidationResult {
valid: boolean
errors: ValidationError[]
warnings: ValidationWarning[]
}
export interface ValidationError {
type: 'type_mismatch' | 'missing_required' | 'unknown_block' | 'unknown_port' | 'cycle_detected'
message: string
blockId?: string
portId?: string
details?: Record<string, unknown>
}
export interface ValidationWarning {
type: 'implicit_conversion' | 'unused_output' | 'unconnected_input'
message: string
blockId?: string
portId?: string
}
// =============================================================================
// Port Compatibility
// =============================================================================
/**
* Check if an output port can connect to an input port
*/
export function canConnect(
outputPort: OutputPort,
inputPort: InputPort
): boolean {
return isTypeCompatible(outputPort.produces, inputPort.accepts)
}
/**
* Check if a specific type can connect to an input port
*/
export function canConnectType(
outputType: PortDataType,
inputPort: InputPort
): boolean {
return isTypeCompatible(outputType, inputPort.accepts)
}
/**
* Get all compatible ports on a target block for a given output port
*/
export function getCompatibleInputPorts(
sourceBlockType: string,
sourcePortId: string,
targetBlockType: string
): InputPort[] {
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
return []
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
if (!sourcePort) return []
return targetBlock.inputs.filter(inputPort =>
canConnect(sourcePort, inputPort)
)
}
/**
* Get all compatible output ports on a source block for a given input port
*/
export function getCompatibleOutputPorts(
sourceBlockType: string,
targetBlockType: string,
targetPortId: string
): OutputPort[] {
if (!hasBlockDefinition(sourceBlockType) || !hasBlockDefinition(targetBlockType)) {
return []
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
if (!targetPort) return []
return sourceBlock.outputs.filter(outputPort =>
canConnect(outputPort, targetPort)
)
}
// =============================================================================
// Connection Validation
// =============================================================================
/**
* Validate a single connection between two blocks
*/
export function validateConnection(
sourceBlockType: string,
sourcePortId: string,
targetBlockType: string,
targetPortId: string
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
// Check source block exists
if (!hasBlockDefinition(sourceBlockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown source block type: ${sourceBlockType}`,
details: { blockType: sourceBlockType },
})
return { valid: false, errors, warnings }
}
// Check target block exists
if (!hasBlockDefinition(targetBlockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown target block type: ${targetBlockType}`,
details: { blockType: targetBlockType },
})
return { valid: false, errors, warnings }
}
const sourceBlock = getBlockDefinition(sourceBlockType)
const targetBlock = getBlockDefinition(targetBlockType)
// Check source port exists
const sourcePort = sourceBlock.outputs.find(p => p.id === sourcePortId)
if (!sourcePort) {
errors.push({
type: 'unknown_port',
message: `Unknown output port "${sourcePortId}" on block "${sourceBlockType}"`,
portId: sourcePortId,
details: { blockType: sourceBlockType, availablePorts: sourceBlock.outputs.map(p => p.id) },
})
return { valid: false, errors, warnings }
}
// Check target port exists
const targetPort = targetBlock.inputs.find(p => p.id === targetPortId)
if (!targetPort) {
errors.push({
type: 'unknown_port',
message: `Unknown input port "${targetPortId}" on block "${targetBlockType}"`,
portId: targetPortId,
details: { blockType: targetBlockType, availablePorts: targetBlock.inputs.map(p => p.id) },
})
return { valid: false, errors, warnings }
}
// Check type compatibility
if (!canConnect(sourcePort, targetPort)) {
errors.push({
type: 'type_mismatch',
message: `Type mismatch: "${sourcePort.produces}" cannot connect to "${targetPort.accepts.join(' | ')}"`,
details: {
sourceType: sourcePort.produces,
targetAccepts: targetPort.accepts,
sourcePort: sourcePortId,
targetPort: targetPortId,
},
})
return { valid: false, errors, warnings }
}
// Check for implicit conversions (warning, not error)
if (sourcePort.produces !== targetPort.type && targetPort.accepts.includes('any')) {
warnings.push({
type: 'implicit_conversion',
message: `Implicit conversion from "${sourcePort.produces}" to "${targetPort.type}"`,
})
}
return { valid: true, errors, warnings }
}
/**
* Validate a port binding
*/
export function validatePortBinding(
binding: PortBinding,
getBlockType: (shapeId: string) => string | undefined
): ValidationResult {
const sourceType = getBlockType(binding.fromShapeId as string)
const targetType = getBlockType(binding.toShapeId as string)
if (!sourceType || !targetType) {
return {
valid: false,
errors: [{
type: 'unknown_block',
message: 'Could not determine block types for binding',
blockId: !sourceType ? binding.fromShapeId as string : binding.toShapeId as string,
}],
warnings: [],
}
}
return validateConnection(
sourceType,
binding.fromPortId,
targetType,
binding.toPortId
)
}
// =============================================================================
// Block Validation
// =============================================================================
/**
* Validate a block's configuration
*/
export function validateBlockConfig(
blockType: string,
config: Record<string, unknown>
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
if (!hasBlockDefinition(blockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown block type: ${blockType}`,
})
return { valid: false, errors, warnings }
}
const definition = getBlockDefinition(blockType)
// If no config schema, any config is valid
if (!definition.configSchema) {
return { valid: true, errors, warnings }
}
// Basic schema validation (could use ajv for full JSON Schema validation)
const schema = definition.configSchema as { properties?: Record<string, unknown> }
if (schema.properties) {
for (const [key, propSchema] of Object.entries(schema.properties)) {
const prop = propSchema as { type?: string; required?: boolean; enum?: unknown[] }
// Check required properties
if (prop.required && !(key in config)) {
errors.push({
type: 'missing_required',
message: `Missing required configuration: ${key}`,
details: { key },
})
}
// Check enum values
if (prop.enum && key in config && !prop.enum.includes(config[key])) {
errors.push({
type: 'type_mismatch',
message: `Invalid value for "${key}": must be one of ${prop.enum.join(', ')}`,
details: { key, value: config[key], allowed: prop.enum },
})
}
}
}
return { valid: errors.length === 0, errors, warnings }
}
/**
* Check if a block has all required inputs satisfied
*/
export function validateRequiredInputs(
blockType: string,
inputValues: Record<string, unknown>,
connectedInputs: string[]
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
if (!hasBlockDefinition(blockType)) {
errors.push({
type: 'unknown_block',
message: `Unknown block type: ${blockType}`,
})
return { valid: false, errors, warnings }
}
const definition = getBlockDefinition(blockType)
for (const input of definition.inputs) {
if (input.required) {
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
const hasConnection = connectedInputs.includes(input.id)
if (!hasValue && !hasConnection) {
errors.push({
type: 'missing_required',
message: `Required input "${input.name}" is not connected or provided`,
portId: input.id,
})
}
}
}
// Warn about unconnected optional inputs
for (const input of definition.inputs) {
if (!input.required) {
const hasValue = input.id in inputValues && inputValues[input.id] !== undefined
const hasConnection = connectedInputs.includes(input.id)
if (!hasValue && !hasConnection && input.defaultValue === undefined) {
warnings.push({
type: 'unconnected_input',
message: `Optional input "${input.name}" has no value or connection`,
portId: input.id,
})
}
}
}
return { valid: errors.length === 0, errors, warnings }
}
// =============================================================================
// Workflow Validation
// =============================================================================
/**
* Detect cycles in a workflow graph
*/
export function detectCycles(
connections: PortBinding[]
): { hasCycle: boolean; cycleNodes?: string[] } {
// Build adjacency list
const graph = new Map<string, Set<string>>()
for (const conn of connections) {
const from = conn.fromShapeId as string
const to = conn.toShapeId as string
if (!graph.has(from)) graph.set(from, new Set())
graph.get(from)!.add(to)
}
// DFS to detect cycles
const visited = new Set<string>()
const recursionStack = new Set<string>()
const cyclePath: string[] = []
function dfs(node: string): boolean {
visited.add(node)
recursionStack.add(node)
cyclePath.push(node)
const neighbors = graph.get(node) || new Set()
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (dfs(neighbor)) return true
} else if (recursionStack.has(neighbor)) {
cyclePath.push(neighbor)
return true
}
}
cyclePath.pop()
recursionStack.delete(node)
return false
}
for (const node of graph.keys()) {
if (!visited.has(node)) {
if (dfs(node)) {
// Extract just the cycle portion
const cycleStart = cyclePath.indexOf(cyclePath[cyclePath.length - 1])
return {
hasCycle: true,
cycleNodes: cyclePath.slice(cycleStart),
}
}
}
}
return { hasCycle: false }
}
/**
* Validate an entire workflow
*/
export function validateWorkflow(
blocks: Array<{ id: string; blockType: string; config: Record<string, unknown> }>,
connections: PortBinding[]
): ValidationResult {
const errors: ValidationError[] = []
const warnings: ValidationWarning[] = []
// Validate each connection
for (const conn of connections) {
const sourceBlock = blocks.find(b => b.id === conn.fromShapeId)
const targetBlock = blocks.find(b => b.id === conn.toShapeId)
if (sourceBlock && targetBlock) {
const result = validateConnection(
sourceBlock.blockType,
conn.fromPortId,
targetBlock.blockType,
conn.toPortId
)
errors.push(...result.errors)
warnings.push(...result.warnings)
}
}
// Check for cycles
const cycleResult = detectCycles(connections)
if (cycleResult.hasCycle) {
errors.push({
type: 'cycle_detected',
message: `Cycle detected in workflow: ${cycleResult.cycleNodes?.join(' -> ')}`,
details: { cycleNodes: cycleResult.cycleNodes },
})
}
// Check for unused outputs (optional warning)
const connectedOutputs = new Set(connections.map(c => `${c.fromShapeId}:${c.fromPortId}`))
for (const block of blocks) {
if (!hasBlockDefinition(block.blockType)) continue
const def = getBlockDefinition(block.blockType)
for (const output of def.outputs) {
if (!connectedOutputs.has(`${block.id}:${output.id}`)) {
// Only warn for non-terminal blocks
if (def.category !== 'output') {
warnings.push({
type: 'unused_output',
message: `Output "${output.name}" on block "${def.name}" is not connected`,
blockId: block.id,
portId: output.id,
})
}
}
}
}
return { valid: errors.length === 0, errors, warnings }
}