Files
2026-10-22 17:07:08 +08:00

124 lines
7.9 KiB
TypeScript

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<string, any> {
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<string, any> = input as Record<string, any>;
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<string>();
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<string>();
const outgoing = new Map<string, string[]>();
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<string>();
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<string>();
for (const value of values) {
validate_workflow(value);
check(!ids.has(value.id), 'duplicate workflow id');
ids.add(value.id);
}
}