import type {Condition, ValueSource, WorkflowNode, workflow_config} from '../../../types/bot/workflow/workflow.ts'; import {assert_json} from './value.ts'; import {node_registry} from '../registry.ts'; const identifier = /^[a-zA-Z0-9_-]{1,100}$/; const forbidden = new Set(['__proto__', 'prototype', 'constructor']); function check(value: unknown, message: string): asserts value { if (!value) throw new Error(`Invalid workflow: ${message}`); } function record(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function name(value: unknown): value is string { return typeof value === 'string' && identifier.test(value) && !forbidden.has(value); } function integer(value: unknown, min: number, max = 2_147_483_647) { return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max; } function source(value: any) { check(record(value), 'value source must be an object'); if (value.kind === 'literal') { assert_json(value.value); return; } check(value.kind === 'ref' && ['trigger', 'vars', 'outputs', 'last_result', 'inputs'].includes(value.root), 'invalid reference root'); check(Array.isArray(value.path) && value.path.length <= 32 && value.path.every((part: unknown) => typeof part === 'string' && !forbidden.has(part) || integer(part, 0)), 'invalid reference path'); } function condition(value: any, depth = 0) { check(depth < 20 && record(value), 'invalid or deeply nested condition'); if (value.op === 'and' || value.op === 'or') { check(Array.isArray(value.items) && value.items.length > 0 && value.items.length <= 100, 'invalid condition items'); value.items.forEach((item: Condition) => condition(item, depth + 1)); } else if (value.op === 'not') condition(value.item, depth + 1); else { check(['eq', 'contains', 'gt', 'lt'].includes(value.op), 'unknown condition operator'); source(value.left); source(value.right); } } export function validate_workflow(input: unknown): asserts input is workflow_config { assert_json(input); const value: Record = input as Record; check(record(value) && value.schema_version === 1 && name(value.id) && typeof value.name === 'string' && typeof value.enabled === 'boolean', 'invalid definition'); const start = value.start; check(record(start), 'missing start'); if (start.type === 'common') check(['never', 'spawn'].includes(start.auto_start), 'invalid common start'); else if (start.type === 'timer') { check(integer(start.interval_ms, 10), 'invalid timer interval'); check(start.auto_start === undefined || typeof start.auto_start === 'boolean', 'invalid timer auto_start'); check(start.mode === undefined || ['once', 'repeat'].includes(start.mode), 'invalid timer mode'); check(start.max_runs === undefined || start.max_runs === null || integer(start.max_runs, 1, 100000), 'invalid timer max_runs'); } else { check(start.type === 'event' && typeof start.event === 'string' && /^[a-zA-Z][a-zA-Z0-9_]{0,99}$/.test(start.event), 'invalid event start'); check(start.auto_start === undefined || typeof start.auto_start === 'boolean', 'invalid event auto_start'); check(start.mode === undefined || ['continuous', 'once'].includes(start.mode), 'invalid event mode'); if (start.condition !== undefined) condition(start.condition); } check(record(value.policy) && ['skip', 'queue'].includes(value.policy.on_busy) && integer(value.policy.max_queue, 0, 1000) && integer(value.policy.max_steps, 1, 100000) && integer(value.policy.timeout_ms, 1), 'invalid execution policy'); check(value.policy.max_concurrent_runs === undefined || integer(value.policy.max_concurrent_runs, 1, 256), 'invalid concurrent run limit'); if (value.initial_vars !== undefined) check(record(value.initial_vars) && Object.keys(value.initial_vars).every(name), 'invalid initial variables'); check(Array.isArray(value.nodes) && value.nodes.length > 0 && value.nodes.length <= 1000, 'invalid node list'); const ids = new Set(); for (const node of value.nodes) { check(record(node) && name(node.id) && !ids.has(node.id) && Object.hasOwn(node_registry, node.type) && record(node.config), 'invalid or duplicate node'); ids.add(node.id); check(node.timeout_ms === undefined || integer(node.timeout_ms, 1), `invalid timeout: ${node.id}`); check(node.name === undefined || typeof node.name === 'string', 'invalid node name'); const config = node.config; switch (node.type) { case 'function': check(typeof config.path === 'string' && config.path.split('/').every(name) && name(config.name) && Array.isArray(config.args) && config.args.length <= 100, 'invalid function'); config.args.forEach(source); break; case 'query': check(name(config.source) && record(config.args) && Object.keys(config.args).every(name) && (config.save_as === undefined || name(config.save_as)), 'invalid query'); Object.values(config.args).forEach(source); break; case 'condition': condition(config.condition); break; case 'sleep': check(['milliseconds', 'ticks'].includes(config.mode) && integer(config.value, 0), 'invalid sleep'); break; case 'error': check(config.codes === undefined || Array.isArray(config.codes) && config.codes.every((code: unknown) => typeof code === 'string'), 'invalid error codes'); break; case 'goto': check(integer(config.max_retries, 0, 10000) && integer(config.delay_ms, 0), 'invalid retry'); break; case 'merge': check(['all', 'any'].includes(config.mode), 'invalid merge mode'); break; case 'log': check(['info', 'warning', 'error'].includes(config.level), 'invalid log level'); source(config.message); break; case 'code': check(typeof config.source === 'string' && config.source.length <= 100000, 'invalid code'); break; } } check(ids.has(value.entry), 'entry does not exist'); check(Array.isArray(value.edges) && value.edges.length <= 10000, 'invalid edges'); const edges = new Set(); const outgoing = new Map(); for (const edge of value.edges) { check(record(edge) && name(edge.id) && !edges.has(edge.id) && ids.has(edge.source) && ids.has(edge.target) && typeof edge.port === 'string', 'invalid or duplicate edge'); const node = value.nodes.find((item: WorkflowNode) => item.id === edge.source) as WorkflowNode; check(node_registry[node.type].config_template.ports.some(port => port.name === edge.port), `invalid port ${edge.port} on ${edge.source}`); edges.add(edge.id); outgoing.set(edge.source, [...outgoing.get(edge.source) ?? [], edge.target]); } for (const node of value.nodes as WorkflowNode[]) { if (node.type === 'goto') check(value.edges.some((edge: any) => edge.source === node.id && edge.port === 'retry'), 'retry target is missing'); if (node.type === 'merge' && node.config.mode === 'all') { check(node.id !== value.entry && value.edges.filter((edge: any) => edge.target === node.id).length >= 2, 'all merge needs at least two incoming edges'); } } const reachable = new Set(); const queue = [value.entry]; for (const id of queue) { if (reachable.has(id)) continue; reachable.add(id); queue.push(...outgoing.get(id) ?? []); } check(reachable.size === ids.size, 'unreachable nodes'); } export function validate_definitions(values: unknown): asserts values is workflow_config[] { check(Array.isArray(values), 'definitions must be an array'); const ids = new Set(); for (const value of values) { validate_workflow(value); check(!ids.has(value.id), 'duplicate workflow id'); ids.add(value.id); } }