import type { WorkflowGraphValidationIssue, WorkflowNode } from './workflows.ts' export interface WorkflowModuleRule { actionId: string referenceFields: string[] requiredInputFields: string[] validate: (node: WorkflowNode) => WorkflowGraphValidationIssue[] } function inputRecord(node: WorkflowNode): Record { return (node.input && typeof node.input === 'object' ? node.input : {}) } function isBlank(value: unknown): boolean { return typeof value !== 'string' || value.trim().length === 0 } function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } function requireString(node: WorkflowNode, field: string, errors: WorkflowGraphValidationIssue[]): void { if (isBlank(inputRecord(node)[field])) { errors.push({ code: 'module_required_field', message: `Node '${node.id}' (${node.actionId}) requires non-empty input.${field}.`, nodeId: node.id, path: `input.${field}`, }) } } const REFERENCE_PATTERN = /\{\{(?:(?:input|step):\/\/[^}]+|[A-Za-z_][\w.-]*)\}\}/ const SHORTHAND_REFERENCE_PATTERN = /\{\{[a-z_][\w.-]*\}\}/i function collectInputStrings(value: unknown, basePath = ''): Array<{ path: string, value: string }> { if (typeof value === 'string') return [{ path: basePath, value }] if (Array.isArray(value)) { return value.flatMap((item, index) => collectInputStrings(item, `${basePath}[${index}]`), ) } if (value && typeof value === 'object') { return Object.entries(value as Record).flatMap(([key, child]) => collectInputStrings(child, basePath ? `${basePath}.${key}` : key), ) } return [] } function fieldPatternToRegExp(pattern: string): RegExp { const parts = pattern.split('*').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) return new RegExp(`^${parts.join('(?:[^.\\[\\]]+|\\[\\d+\\])')}$`) } function matchesFieldPattern(path: string, fields: string[]): boolean { const normalizedPath = path.replace(/\[\d+\]/g, '.*') return fields.some((field) => { if (field.endsWith('.*')) { const prefix = field.slice(0, -2) if (path.startsWith(`${prefix}.`) || normalizedPath.startsWith(`${prefix}.`)) return true } const pattern = fieldPatternToRegExp(field) return pattern.test(path) || pattern.test(normalizedPath) }) } function validateReferenceFields( node: WorkflowNode, referenceFields: string[], errors: WorkflowGraphValidationIssue[], ): void { for (const { path, value } of collectInputStrings(inputRecord(node))) { if (!REFERENCE_PATTERN.test(value)) continue if (matchesFieldPattern(path, referenceFields)) continue errors.push({ code: 'module_reference_field_not_allowed', message: `Node '${node.id}' (${node.actionId}) has a workflow reference in input.${path}, but that field is not reference-enabled.`, nodeId: node.id, path: `input.${path}`, }) } } function validateShorthandReferenceFields( node: WorkflowNode, shorthandReferenceForbiddenFields: string[], errors: WorkflowGraphValidationIssue[], ): void { if (shorthandReferenceForbiddenFields.length === 0) return for (const { path, value } of collectInputStrings(inputRecord(node))) { if (!SHORTHAND_REFERENCE_PATTERN.test(value)) continue const isForbidden = matchesFieldPattern(path, shorthandReferenceForbiddenFields) if (!isForbidden) continue errors.push({ code: 'module_shorthand_reference_not_allowed', message: `Node '${node.id}' (${node.actionId}) has a shorthand workflow reference in input.${path}; use input:// or step:// references in workflow control fields.`, nodeId: node.id, path: `input.${path}`, }) } } function createRule( actionId: string, referenceFields: string[], requiredInputFields: string[], extra?: (node: WorkflowNode, errors: WorkflowGraphValidationIssue[]) => void, shorthandReferenceForbiddenFields: string[] = [], ): WorkflowModuleRule { return { actionId, referenceFields, requiredInputFields, validate(node) { const errors: WorkflowGraphValidationIssue[] = [] for (const field of requiredInputFields) requireString(node, field, errors) extra?.(node, errors) validateReferenceFields(node, referenceFields, errors) validateShorthandReferenceFields(node, shorthandReferenceForbiddenFields, errors) return errors }, } } export const WORKFLOW_MODULE_RULES: Record = { 'llm-completion': createRule('llm-completion', ['prompt'], ['prompt']), 'agent': createRule('agent', ['prompt'], ['prompt']), 'map': createRule('map', ['over'], ['over', 'mapVariable'], undefined, ['over']), 'condition': createRule('condition', ['cases.*.condition'], [], (node, errors) => { const cases = inputRecord(node).cases if (!Array.isArray(cases) || cases.length === 0) { errors.push({ code: 'condition_missing_cases', message: `Condition node '${node.id}' must have at least one case.`, nodeId: node.id, path: 'input.cases', }) return } for (const [index, workflowCase] of cases.entries()) { if (!isRecord(workflowCase)) { errors.push({ code: 'condition_case_invalid', message: `Condition node '${node.id}' case ${index} must be an object.`, nodeId: node.id, path: `input.cases[${index}]`, }) continue } if (isBlank(workflowCase.condition)) { errors.push({ code: 'condition_case_missing_condition', message: `Condition node '${node.id}' case ${index} requires a non-empty condition.`, nodeId: node.id, path: `input.cases[${index}].condition`, }) } } }, ['cases.*.condition']), 'stop': createRule('stop', [], []), 'code-execution': createRule('code-execution', ['code'], ['code'], (node, errors) => { const code = inputRecord(node).code if (typeof code !== 'string' || !/function\s+execute\s*\(/.test(code)) { errors.push({ code: 'code_execution_missing_execute', message: `Code execution node '${node.id}' must declare function execute(...).`, nodeId: node.id, path: 'input.code', }) } const status = inputRecord(node).sandboxStatus if (status === 'pending' || status === 'building') { errors.push({ code: 'code_execution_sandbox_not_ready', message: `Code execution node '${node.id}' sandbox is ${status}.`, nodeId: node.id, path: 'input.sandboxStatus', }) } }), 'document-splitter': createRule('document-splitter', ['file'], ['file']), 'document-cropper': createRule('document-cropper', ['file'], ['file'], (node, errors) => { const indexes = inputRecord(node).indexes if (!Array.isArray(indexes) || indexes.length === 0) { errors.push({ code: 'document_cropper_missing_indexes', message: `Document cropper node '${node.id}' requires at least one index.`, nodeId: node.id, path: 'input.indexes', }) } }), 'document-templater': createRule('document-templater', ['vaultUrl', 'placeholders.*.reference'], ['vaultUrl', 'outputFilename'], (node, errors) => { const placeholders = inputRecord(node).placeholders if (Array.isArray(placeholders)) { for (const [index, placeholder] of placeholders.entries()) { if (!isRecord(placeholder) || isBlank(placeholder.key) || isBlank(placeholder.reference)) { errors.push({ code: 'document_templater_placeholder_incomplete', message: `Document templater node '${node.id}' placeholder ${index} requires key and reference.`, nodeId: node.id, path: `input.placeholders[${index}]`, }) } } } }), 'canvas': createRule('canvas', ['variables.*'], ['canvasId']), 'template': createRule('template', ['variables.*'], ['templateId']), } export function getWorkflowModuleRule(actionId: string): WorkflowModuleRule | undefined { return WORKFLOW_MODULE_RULES[actionId] }