chore: initial commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user