chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
import type {CompiledWorkflow} from '../../../types/bot/workflow/runtime.ts';
import {validate_workflow} from './validate.ts';
import {assert_json} from './value.ts';
import {resolve_fields} from './template.ts';
import {node_registry, start_registry} from '../registry.ts';
import {node_fields, policy_fields} from '../common_template.ts';
import type {Json} from '../../../types/bot/workflow/workflow.ts';
export function compile_workflow(value: unknown): CompiledWorkflow {
assert_json(value);
const normalized = structuredClone(value) as Record<string, any>;
if (normalized && !Array.isArray(normalized) && typeof normalized === 'object') {
const start = normalized.start;
if (start && typeof start.type === 'string' && Object.hasOwn(start_registry, start.type)) {
const module = start_registry[start.type as keyof typeof start_registry];
normalized.start = resolve_fields(module.config_template.config, start);
}
if (normalized.policy !== null && (normalized.policy === undefined || typeof normalized.policy === 'object' && !Array.isArray(normalized.policy))) {
normalized.policy = resolve_fields(policy_fields, normalized.policy ?? {});
}
if (Array.isArray(normalized.nodes)) normalized.nodes = normalized.nodes.map((node: Record<string, any>) => {
if (!node || typeof node !== 'object' || Array.isArray(node) || typeof node.type !== 'string' || !Object.hasOwn(node_registry, node.type)) return node;
const fields = node_registry[node.type as keyof typeof node_registry].config_template.config;
const result = resolve_fields(node_fields, node as Record<string, Json>);
if (node.config && typeof node.config === 'object' && !Array.isArray(node.config)) result.config = resolve_fields(fields, node.config);
return result;
});
}
validate_workflow(normalized);
const definition = normalized;
const graph: CompiledWorkflow = {
definition,
nodes: new Map(definition.nodes.map(node => [node.id, node])),
outgoing: new Map(), incoming: new Map(), join_owners: new Map(),
};
for (const edge of definition.edges) {
graph.outgoing.set(edge.source, [...graph.outgoing.get(edge.source) ?? [], edge]);
graph.incoming.set(edge.target, [...graph.incoming.get(edge.target) ?? [], edge]);
}
graph.join_owners = find_join_owners(graph);
return graph;
}
/** The nearest dominating fork owns each join, including joins downstream of other joins. */
export function find_join_owners(graph: CompiledWorkflow): Map<string, string> {
const ids = [...graph.nodes.keys()];
const dominators = new Map(ids.map(id => [id, new Set(id === graph.definition.entry ? [id] : ids)]));
let changed = true;
while (changed) {
changed = false;
for (const id of ids) {
if (id === graph.definition.entry) continue;
const predecessors = (graph.incoming.get(id) ?? []).map(edge => edge.source);
const common = new Set(dominators.get(predecessors[0]) ?? []);
for (const predecessor of predecessors.slice(1)) {
for (const candidate of common) if (!dominators.get(predecessor)!.has(candidate)) common.delete(candidate);
}
common.add(id);
const previous = dominators.get(id)!;
if (common.size !== previous.size || [...common].some(item => !previous.has(item))) {
dominators.set(id, common); changed = true;
}
}
}
const owners = new Map<string, string>();
for (const node of graph.nodes.values()) {
if (node.type !== 'merge' || node.config.mode !== 'all') continue;
const candidates = [...dominators.get(node.id)!].filter(id => id !== node.id && (graph.outgoing.get(id)?.length ?? 0) > 1);
candidates.sort((a, b) => dominators.get(b)!.size - dominators.get(a)!.size);
if (candidates[0]) owners.set(node.id, candidates[0]);
}
return owners;
}
+65
View File
@@ -0,0 +1,65 @@
import {setImmediate} from 'node:timers/promises';
import {performance} from 'node:perf_hooks';
import type {BranchState, NodeReturn, WorkflowDependencies} from '../../../types/bot/workflow/runtime.ts';
import type {WorkflowNode} from '../../../types/bot/workflow/workflow.ts';
import {node_registry} from '../registry.ts';
import {assert_json} from './value.ts';
export async function execute_node(node: WorkflowNode, state: BranchState, signal: AbortSignal,
deps: WorkflowDependencies): Promise<NodeReturn> {
const controller = new AbortController();
const combined = AbortSignal.any([signal, controller.signal]);
const timeout_ms = node.timeout_ms ?? 30_000;
const timeout_error = Object.assign(new Error('Node execution timed out'), {code: 'NODE_TIMEOUT'});
const started_at = performance.now();
const valid_timeout = Number.isFinite(timeout_ms) && timeout_ms > 0 && timeout_ms <= 2_147_483_647;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
if (valid_timeout) timer = setTimeout(() => controller.abort(timeout_error), timeout_ms);
await setImmediate();
signal.throwIfAborted();
if (!valid_timeout) {
throw Object.assign(new Error('Invalid node timeout'), {code: 'INVALID_NODE_TIMEOUT'});
}
combined.throwIfAborted();
if (!Object.hasOwn(node_registry, node.type)) {
throw Object.assign(new Error('Unknown workflow node type'), {code: 'UNKNOWN_NODE_TYPE'});
}
// Await the handler itself so cancellation never leaves an operation running in the background.
const returned = await node_registry[node.type].execute(node, state, combined, deps);
signal.throwIfAborted();
if (performance.now() - started_at >= timeout_ms) controller.abort(timeout_error);
combined.throwIfAborted();
assert_json(returned);
if (typeof returned.port !== 'string' || returned.port.length === 0 || !returned.result
|| !['success', 'failure'].includes(returned.result.status)
|| !Object.hasOwn(returned.result, 'output')
|| (returned.result.error !== undefined && (typeof returned.result.error.code !== 'string'
|| typeof returned.result.error.message !== 'string' || typeof returned.result.error.node_id !== 'string'))
|| (returned.vars !== undefined && (returned.vars === null || Array.isArray(returned.vars)
|| typeof returned.vars !== 'object'))
|| (returned.retry_counts !== undefined && (returned.retry_counts === null
|| typeof returned.retry_counts !== 'object' || Array.isArray(returned.retry_counts)
|| Object.values(returned.retry_counts).some(value => !Number.isSafeInteger(value) || value < 0)))) {
throw Object.assign(new Error('Invalid node return value'), {code: 'INVALID_NODE_RETURN'});
}
if (!node_registry[node.type].config_template.ports.some(port => port.name === returned.port)) {
throw Object.assign(new Error('Node returned an undeclared port'), {code: 'INVALID_NODE_PORT'});
}
signal.throwIfAborted();
if (performance.now() - started_at >= timeout_ms) throw timeout_error;
return returned;
} catch (error) {
signal.throwIfAborted();
const cause = controller.signal.aborted || (valid_timeout && performance.now() - started_at >= timeout_ms)
? timeout_error : error;
const coded = cause as {code?: unknown; message?: unknown} | null;
const code = typeof coded?.code === 'string' ? coded.code : 'NODE_EXECUTION_FAILED';
const message = typeof coded?.message === 'string' ? coded.message : String(cause);
return {port: 'failure', result: {status: 'failure', output: null,
error: {code: code.slice(0, 256), message: message.slice(0, 4096), node_id: node.id}}};
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
+251
View File
@@ -0,0 +1,251 @@
import {assign, createActor, fromPromise, setup} from 'xstate';
import {randomUUID} from 'node:crypto';
import {isDeepStrictEqual} from 'node:util';
import type {Json} from '../../../types/bot/workflow/workflow.ts';
import type {BranchState, CompiledWorkflow, NodeResult, NodeReturn, RunSnapshot, StopReason,
WorkflowDependencies, WorkflowError, WorkflowRunEvent} from '../../../types/bot/workflow/runtime.ts';
import {execute_node} from './execute.ts';
import {assert_json} from './value.ts';
interface Token {
node_id: string;
edge_id: string | null;
state: BranchState;
joins: Record<string, string>;
bases: Record<string, Record<string, Json>>;
forced?: NodeReturn;
}
interface Completion {token: Token; returned: NodeReturn; execution: number}
interface ExecutionCompletion {execution: number; completed?: Completion; error?: unknown}
export interface WorkflowRun {
run_id: string;
done: Promise<RunSnapshot>;
get_snapshot(): RunSnapshot;
stop(reason?: StopReason): Promise<void>;
}
function failure(code: string, message: string, node_id = ''): WorkflowError {return {code, message, node_id};}
function merge_states(tokens: Token[], base: Record<string, Json>): BranchState {
const state = structuredClone(tokens[0].state);
state.vars = structuredClone(base);
state.inputs = {};
state.outputs = {};
const writes = new Set<string>();
for (const token of tokens) {
for (const [key, value] of Object.entries(token.state.vars)) {
if (Object.hasOwn(base, key) && isDeepStrictEqual(base[key], value)) continue;
if (writes.has(key) && !isDeepStrictEqual(state.vars[key], value)) {
throw Object.assign(new Error(`Conflicting branch variable: ${key}`), {code: 'MERGE_CONFLICT'});
}
writes.add(key);
state.vars[key] = structuredClone(value);
}
for (const [id, result] of Object.entries(token.state.outputs)) {
if (!state.outputs[id] || result.execution > state.outputs[id].execution) state.outputs[id] = structuredClone(result);
}
for (const [key, count] of Object.entries(token.state.retry_counts)) {
state.retry_counts[key] = Math.max(state.retry_counts[key] ?? 0, count);
}
Object.assign(state.inputs, structuredClone(token.state.inputs));
}
state.last_result = structuredClone(tokens.find(token => token.state.last_result?.status === 'failure')?.state.last_result
?? tokens[tokens.length - 1].state.last_result);
return state;
}
export function create_workflow_run(graph: CompiledWorkflow, deps: WorkflowDependencies,
trigger: Record<string, Json> = {}, notify: (event: WorkflowRunEvent) => void = () => {}): WorkflowRun {
assert_json(trigger);
if (!trigger || Array.isArray(trigger) || typeof trigger !== 'object') throw new Error('Invalid workflow trigger');
const run_id = randomUUID();
const controller = new AbortController();
const initial: BranchState = {
trigger: structuredClone(trigger), vars: structuredClone(graph.definition.initial_vars ?? {}),
outputs: {}, last_result: null, inputs: {}, retry_counts: {},
};
const snapshot: RunSnapshot = {
run_id, workflow_id: graph.definition.id, status: 'running', steps: 0, active_nodes: [],
started_at: Date.now(), ended_at: null, stop_reason: null, error: null,
trigger: structuredClone(trigger), trigger_available: true, branches: [],
};
const emit = (event: WorkflowRunEvent) => {try {notify(structuredClone(event));} catch { /* Observers cannot stop execution. */ }};
const update = () => emit({type: 'run_updated', snapshot});
const waiting = new Map<string, Map<string, Token[]>>();
const ready: Token[] = [{node_id: graph.definition.entry, edge_id: null, state: initial, joins: {}, bases: {}}];
const active = new Map<number, Promise<void>>();
const completions: ExecutionCompletion[] = [];
let wake: (() => void) | null = null;
const active_nodes = new Map<number, string>();
let fatal: WorkflowError | null = null;
const token_count = () => ready.length + active.size + snapshot.branches.length
+ [...waiting.values()].reduce((sum, group) => sum + [...group.values()].reduce((n, queue) => n + queue.length, 0), 0);
const join_owners = graph.join_owners;
const enqueue = (token: Token) => {
const node = graph.nodes.get(token.node_id)!;
if (node.type !== 'merge' || node.config.mode === 'any') {ready.push(token); return;}
const incoming = graph.incoming.get(node.id)!;
const key = `${node.id}:${token.joins[node.id] ?? 'root'}`;
let group = waiting.get(key);
if (!group) {group = new Map(); waiting.set(key, group);}
const edge_id = token.edge_id!;
group.set(edge_id, [...group.get(edge_id) ?? [], token]);
if (!incoming.every(edge => (group!.get(edge.id)?.length ?? 0) > 0)) return;
const tokens = incoming.map(edge => group!.get(edge.id)!.shift()!);
if ([...group.values()].every(queue => queue.length === 0)) waiting.delete(key);
let joined: Token;
try {joined = {...tokens[0], state: merge_states(tokens, tokens[0].bases[node.id] ?? initial.vars),
joins: {...tokens[0].joins}, bases: {...tokens[0].bases}};}
catch (error) {
joined = {...tokens[0], joins: {...tokens[0].joins}, bases: {...tokens[0].bases}, forced: {
port: 'failure', result: {status: 'failure', output: null,
error: failure((error as {code?: string}).code ?? 'MERGE_FAILED', String((error as Error).message), node.id)},
}};
}
// Keep lineage when a retry returns to the join without revisiting its owner.
// A fresh owner execution replaces the key; FIFO inputs isolate rounds within it.
joined.bases[node.id] = structuredClone(joined.state.vars);
ready.push(joined);
};
const commit = ({token, returned, execution}: Completion) => {
const state = structuredClone(token.state);
state.vars = {...state.vars, ...returned.vars};
state.retry_counts = {...state.retry_counts, ...returned.retry_counts};
state.outputs[token.node_id] = {...structuredClone(returned.result), execution};
state.last_result = {...structuredClone(returned.result), node_id: token.node_id};
assert_json(state);
emit({type: 'node_finished', run_id, node_id: token.node_id, execution, result: returned.result});
const edges = (graph.outgoing.get(token.node_id) ?? []).filter(edge => edge.port === returned.port);
if (token_count() + Math.max(1, edges.length) > 256) {
fatal = failure('BRANCH_LIMIT_EXCEEDED', 'Workflow exceeded its live branch limit', token.node_id);
return;
}
if (!edges.length) {
snapshot.branches.push(state);
if (returned.result.status === 'failure') fatal ??= returned.result.error
?? failure('UNHANDLED_FAILURE', 'A branch ended with an unhandled failure', token.node_id);
return;
}
const joins = {...token.joins};
const bases = {...token.bases};
if (edges.length) {
const fork_id = randomUUID();
let baseline: Record<string, Json> | undefined;
for (const [join, owner] of join_owners) if (owner === token.node_id) {
joins[join] = fork_id;
// Baselines are immutable; all joins owned by this execution share one copy.
baseline ??= structuredClone(state.vars);
bases[join] = baseline;
}
}
for (const edge of edges) {
const next = structuredClone(state);
next.inputs = {[edge.id]: {source: edge.source, port: edge.port, result: structuredClone(returned.result) as unknown as Json}};
enqueue({node_id: edge.target, edge_id: edge.id, state: next, joins: {...joins}, bases: {...bases}});
}
};
const execute_graph = async () => {
try {
while ((ready.length || active.size) && !fatal && !controller.signal.aborted) {
while (ready.length && active.size < 16 && !fatal && !controller.signal.aborted) {
const token = ready.shift()!;
if (snapshot.steps >= graph.definition.policy.max_steps) {
fatal = failure('STEP_LIMIT_EXCEEDED', 'Workflow exceeded its step limit', token.node_id); break;
}
const execution = ++snapshot.steps;
active_nodes.set(execution, token.node_id);
snapshot.active_nodes = [...active_nodes.values()];
emit({type: 'node_started', run_id, node_id: token.node_id, execution});
update();
const complete = (value: ExecutionCompletion) => {
completions.push(value);
wake?.(); wake = null;
};
const promise = (token.forced ? Promise.resolve(token.forced)
: execute_node(graph.nodes.get(token.node_id)!, structuredClone(token.state), controller.signal, deps))
.then(returned => complete({execution, completed: {token, returned, execution}}), error => complete({execution, error}));
active.set(execution, promise);
}
if (fatal || !active.size) break;
if (!completions.length) await new Promise<void>(resolve => {wake = resolve;});
const finished = completions.shift()!;
active.delete(finished.execution);
active_nodes.delete(finished.execution);
snapshot.active_nodes = [...active_nodes.values()];
if (controller.signal.aborted) break;
if (finished.error) throw finished.error;
commit(finished.completed!);
update();
}
if (!controller.signal.aborted && !fatal && waiting.size) {
fatal = failure('MERGE_INCOMPLETE', 'All-input merge has missing inputs; check exclusive branches and loops');
}
} catch (error) {
if (!controller.signal.aborted) fatal = failure((error as {code?: string})?.code ?? 'RUNTIME_ERROR',
error instanceof Error ? error.message : String(error));
} finally {
if (fatal) controller.abort(new Error(fatal.message));
await Promise.all(active.values());
completions.length = 0;
active.clear(); ready.length = 0; waiting.clear();
snapshot.active_nodes = [];
snapshot.error = fatal;
}
return fatal;
};
const machine = setup({
types: {context: {} as {error: WorkflowError | null}, events: {} as {type: 'STOP'; reason: StopReason}},
actors: {graph: fromPromise(execute_graph)},
actions: {
stop: ({event}) => {
if (snapshot.stop_reason || fatal) return;
snapshot.stop_reason = event.reason;
snapshot.status = 'stopping';
controller.abort(new Error(`Workflow stopped: ${event.reason}`));
update();
},
},
}).createMachine({
id: 'workflow', initial: 'running', context: {error: null},
states: {
running: {
on: {STOP: {actions: 'stop'}},
invoke: {src: 'graph', onDone: {target: 'ending', actions: assign({error: ({event}) => event.output})},
onError: {target: 'failed', actions: assign({error: ({event}) => failure('RUNTIME_ERROR', String(event.error))})}},
},
ending: {always: [
{guard: () => snapshot.stop_reason === 'timeout', target: 'timed_out'},
{guard: () => snapshot.stop_reason !== null, target: 'cancelled'},
{guard: ({context}) => context.error !== null, target: 'failed'},
{target: 'completed'},
]},
completed: {type: 'final'}, failed: {type: 'final'}, cancelled: {type: 'final'}, timed_out: {type: 'final'},
},
});
const actor = createActor(machine);
let resolve!: (value: RunSnapshot) => void;
const done = new Promise<RunSnapshot>(finish => {resolve = finish;});
const timer = setTimeout(() => actor.send({type: 'STOP', reason: 'timeout'}), graph.definition.policy.timeout_ms);
actor.subscribe({next: value => {
if (value.status !== 'done') return;
clearTimeout(timer);
snapshot.status = value.value as RunSnapshot['status'];
snapshot.error = value.context.error;
snapshot.ended_at = Date.now();
update();
resolve(structuredClone(snapshot));
}, error: error => {
clearTimeout(timer); snapshot.status = 'failed'; snapshot.ended_at = Date.now();
snapshot.error = failure('ACTOR_ERROR', String(error)); update(); resolve(structuredClone(snapshot));
}});
actor.start();
return {
run_id, done, get_snapshot: () => structuredClone(snapshot),
async stop(reason = 'manual') {if (snapshot.ended_at === null) actor.send({type: 'STOP', reason}); await done;},
};
}
+693
View File
@@ -0,0 +1,693 @@
import {randomUUID} from 'node:crypto';
import {isDeepStrictEqual} from 'node:util';
import type {Bot} from '@wp2508/mineflayer';
import type {bot_event_handler, bot_event_name, bot_instance_context} from '../../instance/bot_instance_init.ts';
import type {Json, workflow_config} from '../../../types/bot/workflow/workflow.ts';
import type {CompiledWorkflow, RunSnapshot, StartResult, StopReason, WorkflowDependencies,
WorkflowManager as WorkflowManagerContract, WorkflowRunEvent, WorkflowControl, WorkflowCommand,
WorkflowRuntimeState, WorkflowRuntimeSnapshot} from '../../../types/bot/workflow/runtime.ts';
import {compile_workflow} from './compile.ts';
import {create_workflow_run, type WorkflowRun} from './machine.ts';
import {assert_json} from './value.ts';
import {query_registry} from '../registry.ts';
import {resolve_fields} from './template.ts';
import {register_event_start} from '../start_node/event_node.ts';
import {schedule_timer_start} from '../start_node/timer_node.ts';
const max_queue = 256;
const max_trigger_bytes = 65_536;
const max_history_bytes = 5 * 1024 * 1024;
const max_history_runs = 20;
type Request = {graph: CompiledWorkflow; generation: number; trigger: Record<string, Json>; request_id: string};
type Entry = {graph: CompiledWorkflow; state: WorkflowRuntimeState; cleanup?: () => void;
firing: boolean; common_fired: boolean; manually_stopped: boolean; stopping: boolean};
export interface WorkflowManagerOptions {
allow_code?: boolean;
/** Total concurrent runs for this bot, including runs draining cancellation; defaults to 16. */
max_concurrent_runs?: number;
/** Initial persisted configuration revision; zero is the unversioned baseline. */
source_config_revision?: number;
}
function fail(code: string, message: string): never { throw Object.assign(new Error(message), {code}); }
export class WorkflowManager implements WorkflowManagerContract {
private readonly graphs = new Map<string, CompiledWorkflow>();
private readonly entries = new Map<string, Entry>();
private readonly generations = new Map<string, number>();
private readonly commands = new Map<string, {request: WorkflowControl; command: WorkflowCommand}>();
private readonly reload_times = new Map<string, number[]>();
private operation: Promise<void> = Promise.resolve();
private pending_controls = 0;
private source_revision: number;
private source_definitions: workflow_config[];
private runtime_pending = false;
private readonly queue: Request[] = [];
private readonly history = new Map<string, {snapshot: RunSnapshot; bytes: number}>();
private readonly listeners = new Set<(event: WorkflowRunEvent) => void>();
private readonly cleanups: (() => void)[] = [];
private history_bytes = 0;
private readonly active = new Map<string, {run: WorkflowRun; workflow_id: string}>();
private readonly max_concurrent_runs: number;
private spawned_bot: Bot | null = null;
private connection_bot: Bot | null = null;
private launching = false;
private launch_done?: Promise<void>;
private initialized = false;
private disposed = false;
private dispose_task?: Promise<void>;
constructor(private readonly context: bot_instance_context<any>, configs: workflow_config[],
private readonly options: WorkflowManagerOptions = {}) {
this.max_concurrent_runs = options.max_concurrent_runs ?? 16;
if (!Number.isSafeInteger(this.max_concurrent_runs) || this.max_concurrent_runs < 1 || this.max_concurrent_runs > 256) {
fail('INVALID_CONCURRENCY_LIMIT', 'Concurrent run limit must be an integer between 1 and 256');
}
this.source_revision = options.source_config_revision ?? 0;
this.check_revision(this.source_revision);
const compiled = this.compile(configs);
this.source_definitions = structuredClone(configs);
for (const graph of compiled.values()) this.install(graph);
}
private compile(configs: workflow_config[]): Map<string, CompiledWorkflow> {
if (!Array.isArray(configs)) fail('INVALID_WORKFLOW_CONFIG', 'Workflow definitions must be an array');
const compiled = new Map<string, CompiledWorkflow>();
for (const config of configs) {
const graph = compile_workflow(config);
if (compiled.has(graph.definition.id)) fail('DUPLICATE_WORKFLOW_ID', 'Duplicate workflow id');
if (graph.definition.start.type === 'event' && graph.definition.start.event === 'workflow_update') {
fail('INVALID_WORKFLOW_EVENT', 'workflow_update cannot trigger a workflow');
}
compiled.set(graph.definition.id, graph);
}
return compiled;
}
private install(graph: CompiledWorkflow): Entry {
const id = graph.definition.id;
const generation = (this.generations.get(id) ?? 0) + 1;
this.generations.set(id, generation);
const start = graph.definition.start;
const entry: Entry = {graph, firing: false, common_fired: false, manually_stopped: false, stopping: false, state: {
workflow_id: id, generation, enabled: graph.definition.enabled, start_type: start.type,
trigger_state: 'stopped', accepted_count: 0,
max_runs: start.type === 'common' || start.mode === 'once' ? 1
: start.type === 'timer' ? start.max_runs ?? null : null,
next_fire_at: null, active_run_ids: [], queued_count: 0, last_error: null,
}};
this.graphs.set(id, graph);
this.entries.set(id, entry);
return entry;
}
initialize(): void {
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
if (this.initialized) return;
this.initialized = true;
this.spawned_bot = this.context.bot_status === 'online' && this.context.bot_instance?.entity
? this.context.bot_instance : null;
this.connection_bot = this.spawned_bot;
try {
this.listen('spawn', () => {
const bot = this.context.bot_instance;
if (!bot) return;
if (this.spawned_bot && this.spawned_bot !== bot) this.disconnect();
this.spawned_bot = bot;
if (this.connection_bot !== bot) {
this.connection_bot = bot;
for (const entry of this.entries.values()) {
if (entry.stopping) continue;
this.stop_trigger(entry);
entry.manually_stopped = false;
entry.common_fired = false;
entry.state.accepted_count = 0;
entry.state.last_error = null;
this.auto_start(entry);
}
}
this.runtime_changed();
});
this.listen('status_update', status => {
if (status !== 'online') this.disconnect();
});
this.listen('end', () => this.disconnect());
this.listen('kicked', () => this.disconnect());
this.listen('error', () => this.disconnect());
this.listen('connection_lost', () => this.disconnect());
this.listen('instance_created', () => this.disconnect());
for (const entry of this.entries.values()) this.auto_start(entry, true);
} catch (error) {
void this.dispose().catch(() => {});
throw error;
}
}
private listen<E extends bot_event_name>(event: E, callback: bot_event_handler<E>): void {
this.cleanups.push(() => this.context.off(event, callback));
this.context.on(event, callback);
}
private ready(): boolean {
return this.context.bot_status === 'online' && this.spawned_bot !== null
&& this.context.bot_instance === this.spawned_bot && !!this.spawned_bot.entity;
}
private disconnect(): void {
this.spawned_bot = null;
this.queue.length = 0;
for (const entry of this.entries.values()) this.stop_trigger(entry);
for (const {run} of this.active.values()) void run.stop('disconnect').catch(() => {});
this.runtime_changed();
}
private auto_start(entry: Entry, defer_common = false): void {
if (!this.ready() || this.disposed || entry.stopping || !entry.graph.definition.enabled || entry.manually_stopped) return;
const config = entry.graph.definition.start;
if (config.type === 'common') {
if (config.auto_start !== 'spawn' || entry.common_fired) return;
const fire = () => {
if (!this.ready() || this.disposed || this.entries.get(entry.state.workflow_id) !== entry
|| entry.stopping || entry.common_fired || entry.manually_stopped) return;
entry.common_fired = true;
this.fire(entry, {event: 'spawn'});
};
if (defer_common) queueMicrotask(fire); else fire();
} else if (config.auto_start !== false) this.start_trigger(entry);
}
private stop_trigger(entry: Entry, state: WorkflowRuntimeState['trigger_state'] = 'stopped'): void {
const cleanup = entry.cleanup;
entry.cleanup = undefined;
entry.state.trigger_state = state;
entry.state.next_fire_at = null;
cleanup?.();
this.runtime_changed();
}
private start_trigger(entry: Entry): void {
if (entry.stopping) fail('WORKFLOW_STOPPING', 'Workflow is stopping');
if (entry.cleanup || entry.firing) return;
if (!entry.graph.definition.enabled) fail('WORKFLOW_DISABLED', 'Workflow is disabled');
if (!this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned');
entry.manually_stopped = false;
entry.state.last_error = null;
const config = entry.graph.definition.start;
if (config.type === 'event') {
entry.cleanup = register_event_start(this.context, entry.graph.definition, trigger => this.fire(entry, trigger));
entry.state.trigger_state = 'listening';
} else if (config.type === 'timer') {
entry.state.next_fire_at = Date.now() + config.interval_ms;
entry.cleanup = schedule_timer_start(config.interval_ms, trigger => {
entry.cleanup = undefined;
entry.state.next_fire_at = null;
this.fire(entry, trigger);
if (entry.state.trigger_state === 'scheduled' && !entry.cleanup && this.ready() && !this.disposed) {
this.start_trigger(entry);
}
});
entry.state.trigger_state = 'scheduled';
}
this.runtime_changed();
}
private fire(entry: Entry, trigger: Record<string, Json>): void {
if (entry.firing || this.entries.get(entry.state.workflow_id) !== entry || this.disposed || !this.ready()
|| entry.stopping || entry.manually_stopped || entry.state.trigger_state === 'reloading') return;
if (entry.state.start_type !== 'common'
&& !['listening', 'scheduled'].includes(entry.state.trigger_state)) return;
entry.firing = true;
const previous = entry.state.trigger_state;
const last = entry.state.max_runs !== null && entry.state.accepted_count + 1 >= entry.state.max_runs;
// Detach finite triggers before start() publishes any reentrant run notifications.
if (last) this.stop_trigger(entry, 'exhausted');
try {
const result = this.start(entry.state.workflow_id, trigger);
if (result.status !== 'skipped') entry.state.accepted_count++;
else if (last) entry.state.trigger_state = previous;
} catch (error) {
this.stop_trigger(entry, 'error');
entry.state.last_error = this.error(error);
} finally {
entry.firing = false;
if (last && entry.state.trigger_state === 'listening' && !entry.cleanup && this.ready()) this.start_trigger(entry);
this.runtime_changed();
}
}
private available(graph: CompiledWorkflow): boolean {
const entry = this.entries.get(graph.definition.id);
return entry?.graph === graph && !entry.stopping && this.active.size < this.max_concurrent_runs
&& [...this.active.values()].filter(item => item.workflow_id === graph.definition.id).length
< (graph.definition.policy.max_concurrent_runs ?? 1);
}
start(workflow_id: string, trigger: Record<string, Json> = {}): StartResult {
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
const graph = this.graphs.get(workflow_id);
if (!graph) fail('WORKFLOW_NOT_FOUND', `Unknown workflow: ${workflow_id}`);
const entry = this.entries.get(workflow_id)!;
if (entry.state.trigger_state === 'reloading') fail('WORKFLOW_RELOADING', 'Workflow is being replaced');
if (entry.stopping) fail('WORKFLOW_STOPPING', 'Workflow is stopping');
if (!graph.definition.enabled) fail('WORKFLOW_DISABLED', `Workflow is disabled: ${workflow_id}`);
if (!this.initialized || !this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned');
assert_json(trigger);
if (trigger === null || typeof trigger !== 'object' || Array.isArray(trigger)
|| Buffer.byteLength(JSON.stringify(trigger)) > max_trigger_bytes) {
fail('INVALID_WORKFLOW_TRIGGER', 'Workflow trigger must be a bounded JSON object');
}
const copy = structuredClone(trigger);
if (!this.available(graph) || this.launching) {
if (graph.definition.policy.on_busy === 'skip' || this.queue.length >= max_queue
|| this.queue.filter(item => item.graph === graph).length >= graph.definition.policy.max_queue) {
return {status: 'skipped'};
}
const request_id = randomUUID();
this.queue.push({graph, generation: entry.state.generation, trigger: copy, request_id});
this.runtime_changed();
queueMicrotask(() => this.pump());
return {status: 'queued', request_id};
}
return {status: 'started', run_id: this.launch(graph, copy)};
}
private launch(graph: CompiledWorkflow, trigger: Record<string, Json>): string {
this.launching = true;
let finish_launch!: () => void;
this.launch_done = new Promise(resolve => { finish_launch = resolve; });
const notifications: WorkflowRunEvent[] = [];
let assigned = false;
try {
const run = create_workflow_run(graph, this.dependencies(), trigger, event => {
if (!assigned) notifications.push(event);
else this.publish(event);
});
this.active.set(run.run_id, {run, workflow_id: graph.definition.id});
this.runtime_changed();
assigned = true;
void run.done.then(snapshot => {
this.remember(snapshot);
this.active.delete(run.run_id);
this.runtime_changed();
this.pump();
}).catch(() => {
this.active.delete(run.run_id);
this.runtime_changed();
this.pump();
});
if (this.disposed || !this.ready()) {
void run.stop(this.disposed ? 'disposed' : 'disconnect').catch(() => {});
}
for (const event of notifications) this.publish(event);
return run.run_id;
} finally {
this.launching = false;
finish_launch();
}
}
private pump(): void {
if (this.disposed || this.launching || !this.ready()) return;
for (let index = this.queue.length - 1; index >= 0; index--) {
const request = this.queue[index];
const entry = this.entries.get(request.graph.definition.id);
if (!entry || entry.graph !== request.graph || entry.state.generation !== request.generation
|| entry.stopping || entry.state.trigger_state === 'reloading') this.queue.splice(index, 1);
}
while (this.active.size < this.max_concurrent_runs && this.ready() && !this.disposed) {
// A busy definition must not block other workflows with free capacity.
const index = this.queue.findIndex(request => this.available(request.graph));
if (index < 0) break;
const request = this.queue.splice(index, 1)[0];
this.runtime_changed();
try { this.launch(request.graph, request.trigger); }
catch { /* A failed launch must not strand later requests. */ }
}
}
private dependencies(): WorkflowDependencies {
const bot = this.context.bot_instance!;
const functions = this.context.function_manager;
const info = this.context.info_manager;
const check = (signal: AbortSignal) => {
signal.throwIfAborted();
if (this.disposed || !this.ready() || this.context.bot_instance !== bot
|| this.context.function_manager !== functions || this.context.info_manager !== info) {
fail('WORKFLOW_CONTEXT_CHANGED', 'Bot runtime changed during workflow execution');
}
};
return {
...(!this.options.allow_code ? {execute_code: async () =>
fail('CODE_EXECUTION_DISABLED', 'Workflow code execution is disabled')} : {}),
run_function: async (path, name, args, signal) => {
check(signal);
if (!functions) fail('FUNCTION_MANAGER_UNAVAILABLE', 'Bot function manager is unavailable');
return functions.run(path, name, args, () => check(signal), {signal});
},
query: async (source, args, signal) => {
check(signal);
if (!Object.hasOwn(query_registry, source)) fail('UNKNOWN_QUERY_SOURCE', `Unknown workflow query source: ${source}`);
const provider = query_registry[source as keyof typeof query_registry];
let config: Record<string, Json>;
try {config = resolve_fields(provider.config_template.config, args);}
catch {fail('INVALID_QUERY_ARGUMENTS', 'Invalid workflow query arguments');}
return provider.query_info({info_manager: info}, config);
},
sleep: (mode, value, signal) => {
check(signal);
return new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined;
let ticks = 0;
let settled = false;
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener('abort', abort);
bot.off('physicsTick', tick);
bot.off('end', end);
};
const finish = (error?: unknown) => {
if (settled) return;
settled = true;
cleanup();
if (error !== undefined) reject(error); else resolve();
};
const abort = () => finish(signal.reason ?? new Error('Workflow sleep cancelled'));
const end = () => finish(new Error('Bot disconnected during workflow sleep'));
const tick = () => {
try { check(signal); if (++ticks >= value) finish(); }
catch (error) { finish(error); }
};
try {
signal.addEventListener('abort', abort, {once: true});
if (signal.aborted) { abort(); return; }
if (value === 0) { finish(); return; }
bot.on('end', end);
if (mode === 'milliseconds') timer = setTimeout(() => {
try { check(signal); finish(); } catch (error) { finish(error); }
}, value);
else bot.on('physicsTick', tick);
} catch (error) { finish(error); }
});
},
log: (level, message) => {
const logger = this.context.logger;
if (logger) this.context.log([logger[level](message)]);
},
};
}
private remember(snapshot: RunSnapshot): void {
const previous = this.history.get(snapshot.run_id);
if (previous) { this.history.delete(snapshot.run_id); this.history_bytes -= previous.bytes; }
let bytes = Buffer.byteLength(JSON.stringify(snapshot));
if (bytes > max_history_bytes) {
snapshot = {...snapshot, branches: []};
bytes = Buffer.byteLength(JSON.stringify(snapshot));
}
this.history.set(snapshot.run_id, {snapshot: structuredClone(snapshot), bytes});
this.history_bytes += bytes;
while (this.history.size > max_history_runs || this.history_bytes > max_history_bytes) {
const oldest = this.history.entries().next().value!;
this.history.delete(oldest[0]);
this.history_bytes -= oldest[1].bytes;
}
}
private publish(event: WorkflowRunEvent): void {
if (event.type === 'run_updated' && event.snapshot.ended_at !== null) this.remember(event.snapshot);
try { this.context.emit('workflow_update', structuredClone(event)); } catch {}
for (const listener of Array.from(this.listeners)) {
if (!this.listeners.has(listener)) continue;
try { listener(structuredClone(event)); } catch {}
}
}
async stop(run_id: string, reason: StopReason = 'manual'): Promise<void> {
const active = this.active.get(run_id);
if (active) await active.run.stop(reason);
else if (!this.history.has(run_id)) fail('WORKFLOW_RUN_NOT_FOUND', `Unknown workflow run: ${run_id}`);
}
get_snapshot(run_id: string): RunSnapshot | undefined {
const active = this.active.get(run_id);
if (active) return active.run.get_snapshot();
const snapshot = this.history.get(run_id)?.snapshot;
return snapshot ? structuredClone(snapshot) : undefined;
}
get_snapshots(): RunSnapshot[] {
const snapshots = Array.from(this.history.values(), entry => structuredClone(entry.snapshot));
for (const {run} of this.active.values()) if (!this.history.has(run.run_id)) snapshots.push(run.get_snapshot());
return snapshots;
}
on_update(callback: (event: WorkflowRunEvent) => void): () => void {
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
const listener = (event: WorkflowRunEvent) => callback(event);
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
private error(error: unknown): {code: string; message: string} {
return {code: typeof (error as any)?.code === 'string' ? (error as any).code : 'WORKFLOW_OPERATION_FAILED',
message: error instanceof Error ? error.message : 'Workflow operation failed'};
}
private check_revision(revision: number): void {
if (!Number.isSafeInteger(revision) || revision < 0) fail('INVALID_SOURCE_REVISION', 'Invalid workflow source revision');
}
private serialize<T>(operation: () => Promise<T> | T): Promise<T> {
const task = this.operation.then(() => {
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
return operation();
});
this.operation = task.then(() => {}, () => {});
return task;
}
apply(configs: workflow_config[], source_revision?: number): Promise<void> {
// Compilation and cloning finish before any controller or running graph is touched.
let compiled: Map<string, CompiledWorkflow>;
let definitions: workflow_config[];
try {
compiled = this.compile(configs);
definitions = structuredClone(configs);
if (source_revision !== undefined) this.check_revision(source_revision);
} catch (error) { return Promise.reject(error); }
return this.serialize(async () => {
if (source_revision !== undefined) {
if (source_revision < this.source_revision) fail('STALE_SOURCE_REVISION', 'Workflow source revision is stale');
if (source_revision === this.source_revision) {
if (!isDeepStrictEqual(definitions, this.source_definitions)) {
fail('SOURCE_REVISION_CONFLICT', 'Workflow source revision has different content');
}
return;
}
}
const replaced = [...this.entries.values()].filter(entry =>
!isDeepStrictEqual(entry.graph.definition, compiled.get(entry.state.workflow_id)?.definition));
// Connection events may overwrite trigger_state while cancellation is draining.
for (const entry of replaced) entry.stopping = true;
const ids = new Set(replaced.map(entry => entry.state.workflow_id));
try {
for (const entry of replaced) this.stop_trigger(entry, 'reloading');
this.remove_queue(ids);
await Promise.all(replaced.map(entry => this.drain(entry.state.workflow_id, 'updated')));
} catch (error) {
for (const entry of replaced) {
entry.manually_stopped = true;
entry.state.last_error = this.error(error);
this.stop_trigger(entry, 'error');
}
throw error;
}
if (this.disposed) return;
for (const id of ids) { this.entries.delete(id); this.graphs.delete(id); }
for (const [id, graph] of compiled) {
if (!this.entries.has(id)) this.auto_start(this.install(graph));
}
this.source_definitions = definitions;
if (source_revision !== undefined) this.source_revision = source_revision;
this.runtime_changed();
this.pump();
});
}
private remove_queue(ids: Set<string>): void {
for (let index = this.queue.length - 1; index >= 0; index--) {
if (ids.has(this.queue[index].graph.definition.id)) this.queue.splice(index, 1);
}
this.runtime_changed();
}
private async drain(workflow_id: string, reason: StopReason): Promise<void> {
await this.launch_done;
await Promise.all([...this.active.values()].filter(active => active.workflow_id === workflow_id)
.map(({run}) => run.stop(reason)));
}
control(request: WorkflowControl): WorkflowCommand {
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
assert_json(request);
if (!request || typeof request !== 'object' || Array.isArray(request)
|| typeof request.command_id !== 'string' || !/^[a-zA-Z0-9_-]{1,100}$/.test(request.command_id)
|| !['run_once', 'trigger_start', 'trigger_stop', 'stop', 'stop_run', 'reload'].includes(request.action)
|| Object.keys(request).some(key => !['command_id', 'action', 'workflow_id', 'run_id', 'expected_generation', 'input'].includes(key))
|| request.expected_generation !== undefined && (!Number.isSafeInteger(request.expected_generation) || request.expected_generation < 1)
|| request.workflow_id !== undefined && (typeof request.workflow_id !== 'string' || request.workflow_id.length > 100)
|| request.run_id !== undefined && (typeof request.run_id !== 'string' || request.run_id.length > 100)) {
fail('INVALID_WORKFLOW_CONTROL', 'Invalid workflow control request');
}
if (request.input !== undefined && (request.input === null || typeof request.input !== 'object'
|| Array.isArray(request.input) || Buffer.byteLength(JSON.stringify(request.input)) > max_trigger_bytes)) {
fail('INVALID_WORKFLOW_TRIGGER', 'Workflow input must be a bounded JSON object');
}
const previous = this.commands.get(request.command_id);
if (previous) {
if (!isDeepStrictEqual(previous.request, request)) fail('COMMAND_ID_CONFLICT', 'Command id has different content');
return structuredClone(previous.command);
}
if (this.pending_controls >= 32) fail('WORKFLOW_CONTROL_BUSY', 'Too many pending workflow controls');
const copy = structuredClone(request);
const command: WorkflowCommand = {command_id: request.command_id, action: request.action,
workflow_id: request.workflow_id ?? null, state: 'accepted'};
this.commands.set(command.command_id, {request: copy, command});
this.pending_controls++;
this.runtime_changed();
void this.serialize(async () => {
if (copy.action === 'stop_run') {
if (!copy.run_id) fail('INVALID_WORKFLOW_CONTROL', 'stop_run requires run_id');
const snapshot = this.get_snapshot(copy.run_id);
if (!snapshot) fail('WORKFLOW_RUN_NOT_FOUND', 'Unknown workflow run');
if (copy.workflow_id !== undefined && copy.workflow_id !== snapshot.workflow_id) {
fail('WORKFLOW_RUN_MISMATCH', 'Run belongs to another workflow');
}
command.workflow_id = snapshot.workflow_id;
this.expect_generation(copy, this.entries.get(snapshot.workflow_id));
await this.stop(copy.run_id);
command.result = {stopped: true};
return;
}
const entry = copy.workflow_id ? this.entries.get(copy.workflow_id) : undefined;
if (!entry) fail('WORKFLOW_NOT_FOUND', 'Unknown workflow');
this.expect_generation(copy, entry);
switch (copy.action) {
case 'run_once': command.result = this.start(entry.state.workflow_id, copy.input ?? {}); break;
case 'trigger_start':
if (entry.state.start_type === 'common') fail('INVALID_TRIGGER_TYPE', 'Common workflows have no recurring trigger');
if (!entry.cleanup && !entry.firing) {
if (!this.ready()) fail('BOT_NOT_READY', 'Bot must be online and spawned');
entry.state.accepted_count = 0;
this.start_trigger(entry);
}
command.result = {generation: entry.state.generation};
break;
case 'trigger_stop':
if (entry.state.start_type === 'common') fail('INVALID_TRIGGER_TYPE', 'Common workflows have no recurring trigger');
entry.manually_stopped = true;
this.stop_trigger(entry);
command.result = {stopped: true};
break;
case 'stop':
entry.manually_stopped = true;
entry.stopping = true;
this.stop_trigger(entry);
this.remove_queue(new Set([entry.state.workflow_id]));
try { await this.drain(entry.state.workflow_id, 'manual'); }
finally { entry.stopping = false; }
command.result = {stopped: true};
break;
case 'reload': {
const now = Date.now();
const recent = (this.reload_times.get(entry.state.workflow_id) ?? []).filter(time => now - time < 10_000);
if (recent.length >= 10) fail('WORKFLOW_RELOAD_RATE_LIMIT', 'Workflow reload limit exceeded');
recent.push(now);
this.reload_times.set(entry.state.workflow_id, recent);
const graph = this.compile([entry.graph.definition]).get(entry.state.workflow_id)!;
entry.stopping = true;
try {
this.stop_trigger(entry, 'reloading');
this.remove_queue(new Set([entry.state.workflow_id]));
await this.drain(entry.state.workflow_id, 'reloaded');
} catch (error) {
entry.manually_stopped = true;
entry.state.last_error = this.error(error);
this.stop_trigger(entry, 'error');
throw error;
}
if (this.disposed) fail('WORKFLOW_MANAGER_DISPOSED', 'Workflow manager is disposed');
const replacement = this.install(graph);
this.auto_start(replacement);
command.result = {generation: replacement.state.generation};
break;
}
}
}).then(() => { command.state = 'completed'; }, error => {
command.state = 'failed'; command.error = this.error(error);
}).finally(() => {
this.pending_controls--;
while (this.commands.size > 100) {
const oldest = [...this.commands].find(([, item]) => item.command.state !== 'accepted');
if (!oldest) break;
this.commands.delete(oldest[0]);
}
this.runtime_changed();
});
return structuredClone(command);
}
private expect_generation(request: WorkflowControl, entry?: Entry): void {
if (request.expected_generation !== undefined && entry?.state.generation !== request.expected_generation) {
fail('WORKFLOW_GENERATION_CONFLICT', 'Workflow generation has changed');
}
}
get_command(command_id: string): WorkflowCommand | undefined {
const command = this.commands.get(command_id)?.command;
return command ? structuredClone(command) : undefined;
}
get_runtime_snapshot(): WorkflowRuntimeSnapshot {
return {source_config_revision: this.source_revision,
workflows: [...this.entries.values()].map(entry => ({...structuredClone(entry.state),
active_run_ids: [...this.active].filter(([, active]) => active.workflow_id === entry.state.workflow_id)
.map(([id]) => id),
queued_count: this.queue.filter(request => request.graph === entry.graph
&& request.generation === entry.state.generation).length})),
commands: [...this.commands.values()].map(item => structuredClone(item.command))};
}
private runtime_changed(): void {
if (!this.initialized || this.runtime_pending || this.disposed) return;
this.runtime_pending = true;
queueMicrotask(() => {
this.runtime_pending = false;
if (!this.disposed) this.publish({type: 'runtime_updated', snapshot: this.get_runtime_snapshot()});
});
}
dispose(): Promise<void> {
if (this.dispose_task) return this.dispose_task;
this.disposed = true;
this.spawned_bot = null;
this.queue.length = 0;
for (const entry of this.entries.values()) this.stop_trigger(entry);
for (const cleanup of this.cleanups.splice(0).reverse()) {
try { cleanup(); } catch {}
}
const launching = this.launch_done;
this.dispose_task = Promise.resolve().then(async () => {
try { await launching; await Promise.all([...this.active.values()].map(({run}) => run.stop('disposed'))); }
finally { await this.operation; this.listeners.clear(); }
});
return this.dispose_task;
}
}
export function create_workflow_manager(context: bot_instance_context<any>, configs: workflow_config[],
options: WorkflowManagerOptions = {}): WorkflowManager {
const manager = new WorkflowManager(context, configs, options);
manager.initialize();
return manager;
}
+8
View File
@@ -0,0 +1,8 @@
import type {RunSnapshot} from '../../../types/bot/workflow/runtime.ts';
export type RunSummary = Omit<RunSnapshot, 'branches' | 'trigger'>;
/** Full trigger data is fetched on demand; live summaries never broadcast event payloads. */
export function summarize_run({branches, trigger, ...summary}: RunSnapshot): RunSummary {
return {...summary, trigger_available: trigger !== undefined};
}
+111
View File
@@ -0,0 +1,111 @@
import type {WorkflowField, WorkflowOutputTemplate} from '../../../types/bot/workflow/metadata.ts';
import type {Json} from '../../../types/bot/workflow/workflow.ts';
import {assert_json} from './value.ts';
function error(message: string): never {throw new Error(`Invalid workflow template: ${message}`);}
const safe_name = (name: string) => /^[a-zA-Z0-9_-]{1,100}$/.test(name) && !['__proto__', 'prototype', 'constructor'].includes(name);
const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value);
export function validate_i18n(value: unknown): void {
if (!Array.isArray(value) || !value.length) error('missing translations');
const languages = new Set<string>();
for (const item of value) {
if (!object(item) || typeof item.lang !== 'string' || !item.lang || languages.has(item.lang)
|| typeof item.name !== 'string' || !item.name.trim() || typeof item.description !== 'string' || !item.description.trim()) {
error('invalid translations');
}
languages.add(item.lang);
}
}
export function validate_output(value: WorkflowOutputTemplate, depth = 0): void {
if (depth > 32 || !object(value) || !['string', 'number', 'boolean', 'null', 'object', 'array', 'json'].includes(value.type)) error('invalid output');
validate_i18n(value.i18n);
if (value.nullable !== undefined && typeof value.nullable !== 'boolean') error('invalid nullable output');
if (value.optional !== undefined && typeof value.optional !== 'boolean') error('invalid optional output');
if (value.properties !== undefined) {
if (value.type !== 'object' || !object(value.properties)) error('invalid output properties');
for (const [name, child] of Object.entries(value.properties)) {
if (!safe_name(name)) error('invalid output property name');
validate_output(child, depth + 1);
}
}
if (value.items !== undefined) {if (value.type !== 'array') error('items require array'); validate_output(value.items, depth + 1);}
if (value.additional_properties !== undefined) {
if (value.type !== 'object') error('additional properties require object');
validate_output(value.additional_properties, depth + 1);
}
}
export function validate_fields(fields: WorkflowField[], depth = 0): void {
if (depth > 20 || !Array.isArray(fields) || fields.length > 100) error('invalid field list');
const names = new Set<string>();
for (const field of fields) {
if (!object(field) || !safe_name(field.name) || names.has(field.name) || typeof field.required !== 'boolean'
|| !['string', 'number', 'boolean', 'select', 'object', 'array', 'map', 'value_source', 'condition', 'code'].includes(field.type)) error('invalid field');
names.add(field.name); validate_i18n(field.i18n);
if (field.nullable !== undefined && typeof field.nullable !== 'boolean') error('invalid nullable field');
if (field.type === 'select') {
if (!Array.isArray(field.options) || !field.options.length) error('select needs options');
const values = new Set<string | number>();
for (const option of field.options) {
if (!object(option) || !['string', 'number'].includes(typeof option.value) || values.has(option.value)) error('invalid select option');
assert_json(option.value); values.add(option.value); validate_i18n(option.i18n);
}
}
if (field.type === 'object') validate_fields(field.fields!, depth + 1);
if (field.type === 'array' || field.type === 'map') validate_fields([field.item!], depth + 1);
if (field.min !== undefined && !Number.isFinite(field.min) || field.max !== undefined && !Number.isFinite(field.max)
|| field.min !== undefined && field.max !== undefined && field.min > field.max) error('invalid number range');
if (field.integer !== undefined && typeof field.integer !== 'boolean') error('invalid integer constraint');
for (const limit of [field.min_length, field.max_length]) {
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) error('invalid length constraint');
}
if (field.min_length !== undefined && field.max_length !== undefined && field.min_length > field.max_length) error('invalid length range');
if (Object.hasOwn(field, 'default')) {assert_json(field.default); check_field(field, field.default);}
}
}
function check_field(field: WorkflowField, value: unknown): void {
if (value === null && field.nullable) return;
let valid = false;
switch (field.type) {
case 'string': case 'code': valid = typeof value === 'string' && (field.max_length === undefined || value.length <= field.max_length)
&& (field.min_length === undefined || value.length >= field.min_length); break;
case 'number': valid = typeof value === 'number' && Number.isFinite(value)
&& (!field.integer || Number.isSafeInteger(value)) && (field.min === undefined || value >= field.min)
&& (field.max === undefined || value <= field.max); break;
case 'boolean': valid = typeof value === 'boolean'; break;
case 'select': valid = field.options!.some(option => option.value === value); break;
case 'array':
valid = Array.isArray(value) && (field.min === undefined || value.length >= field.min)
&& (field.max === undefined || value.length <= field.max);
if (valid) for (const item of value as unknown[]) check_field(field.item!, item); break;
case 'object': valid = object(value); if (valid) resolve_fields(field.fields!, value as Record<string, Json>); break;
case 'map': valid = object(value); if (valid) for (const item of Object.values(value)) check_field(field.item!, item); break;
case 'value_source': valid = object(value) && (value.kind === 'literal' || value.kind === 'ref'); break;
case 'condition': valid = object(value) && typeof value.op === 'string'; break;
}
if (!valid) error(`invalid value for ${field.name}`);
}
/** Applies defaults only to missing own properties. Explicit invalid values never fall back. */
export function resolve_fields(fields: WorkflowField[], input: Record<string, Json>): Record<string, Json> {
assert_json(input);
if (!object(input)) error('configuration must be an object');
const result = structuredClone(input);
for (const field of fields) {
if (!Object.hasOwn(result, field.name)) {
if (Object.hasOwn(field, 'default')) result[field.name] = structuredClone(field.default!);
else if (field.required) error(`missing ${field.name}`);
else continue;
}
check_field(field, result[field.name]);
if (field.type === 'object') result[field.name] = resolve_fields(field.fields!, result[field.name] as Record<string, Json>);
if (field.type === 'array' && field.item!.type === 'object') result[field.name] = (result[field.name] as Record<string, Json>[])
.map(item => resolve_fields(field.item!.fields!, item));
if (field.type === 'map' && field.item!.type === 'object') result[field.name] = Object.fromEntries(
Object.entries(result[field.name] as Record<string, Record<string, Json>>).map(([key, item]) => [key, resolve_fields(field.item!.fields!, item)]));
}
return result;
}
+123
View File
@@ -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);
}
}
+132
View File
@@ -0,0 +1,132 @@
import {isDeepStrictEqual} from 'node:util';
import type {BranchState} from '../../../types/bot/workflow/runtime.ts';
import type {Condition, Json, ValueSource} from '../../../types/bot/workflow/workflow.ts';
const unsafe_keys = new Set(['__proto__', 'prototype', 'constructor']);
const max_depth = 64;
const max_entries = 100_000;
const max_bytes = 1_048_576;
function invalid(code: string, message: string): never {
throw Object.assign(new Error(message), {code});
}
export function assert_json(value: unknown): asserts value is Json {
const ancestors = new Set<object>();
let entries = 0;
let bytes = 0;
const visit = (item: unknown, depth: number): void => {
if (depth > max_depth || ++entries > max_entries) {
invalid('INVALID_JSON', 'JSON depth or entry limit exceeded');
}
if (item === null || typeof item === 'boolean') {
bytes += item === null ? 4 : item ? 4 : 5;
} else if (typeof item === 'number') {
if (!Number.isFinite(item)) invalid('INVALID_JSON', 'JSON numbers must be finite');
bytes += String(item).length;
} else if (typeof item === 'string') {
if (item.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded');
bytes += Buffer.byteLength(JSON.stringify(item));
} else if (typeof item === 'object') {
const array = Array.isArray(item);
const prototype = Object.getPrototypeOf(item);
if (!array && prototype !== Object.prototype && prototype !== null) {
invalid('INVALID_JSON', 'JSON objects must have a plain prototype');
}
if (ancestors.has(item)) invalid('INVALID_JSON', 'JSON must not contain cycles');
ancestors.add(item);
const keys = Reflect.ownKeys(item);
if (keys.length > max_entries || (array && item.length > max_entries)) {
invalid('INVALID_JSON', 'JSON entry limit exceeded');
}
bytes += 2;
let array_entries = 0;
for (const key of keys) {
if (array && key === 'length') continue;
if (typeof key !== 'string' || unsafe_keys.has(key)) {
invalid('INVALID_JSON', 'JSON contains an unsafe key');
}
const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
if (!descriptor.enumerable || !('value' in descriptor)) {
invalid('INVALID_JSON', 'JSON properties must be enumerable data properties');
}
if (array) {
const index = Number(key);
if (!Number.isSafeInteger(index) || index < 0 || String(index) !== key || index >= item.length) {
invalid('INVALID_JSON', 'JSON arrays must only contain indexed values');
}
array_entries++;
} else {
if (key.length > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded');
bytes += Buffer.byteLength(JSON.stringify(key)) + 1;
}
bytes++;
visit(descriptor.value, depth + 1);
}
if (array && array_entries !== item.length) invalid('INVALID_JSON', 'JSON arrays must not contain holes');
ancestors.delete(item);
} else {
invalid('INVALID_JSON', 'Value is not JSON');
}
if (bytes > max_bytes) invalid('INVALID_JSON', 'JSON size limit exceeded');
};
visit(value, 0);
}
export function resolve_value(source: ValueSource, state: BranchState): Json {
if (source.kind === 'literal') {
assert_json(source.value);
return source.value;
}
if (source.kind !== 'ref' || !['trigger', 'vars', 'outputs', 'last_result', 'inputs'].includes(source.root)
|| !Array.isArray(source.path) || source.path.length > max_depth) {
invalid('INVALID_VALUE_SOURCE', 'Invalid value reference');
}
let value: unknown = state[source.root];
for (const segment of source.path) {
if ((typeof segment !== 'string' && typeof segment !== 'number')
|| (typeof segment === 'number' && (!Number.isSafeInteger(segment) || segment < 0))
|| unsafe_keys.has(String(segment))) {
invalid('INVALID_VALUE_SOURCE', 'Invalid value reference path');
}
if (value === null || typeof value !== 'object') invalid('VALUE_NOT_FOUND', 'Value reference was not found');
const descriptor = Object.getOwnPropertyDescriptor(value, segment);
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
invalid('VALUE_NOT_FOUND', 'Value reference was not found');
}
value = descriptor.value;
}
assert_json(value);
return value;
}
export function evaluate_condition(condition: Condition, state: BranchState): boolean {
let entries = 0;
const evaluate = (item: Condition, depth: number): boolean => {
if (depth > max_depth || ++entries > 10_000) invalid('INVALID_CONDITION', 'Condition limit exceeded');
switch (item.op) {
case 'and': return item.items.every(child => evaluate(child, depth + 1));
case 'or': return item.items.some(child => evaluate(child, depth + 1));
case 'not': return !evaluate(item.item, depth + 1);
case 'eq': return isDeepStrictEqual(resolve_value(item.left, state), resolve_value(item.right, state));
case 'contains': {
const left = resolve_value(item.left, state);
const right = resolve_value(item.right, state);
if (typeof left === 'string' && typeof right === 'string') return left.includes(right);
if (Array.isArray(left)) return left.some(value => isDeepStrictEqual(value, right));
invalid('INVALID_CONDITION', 'contains requires a string pair or an array');
}
case 'gt':
case 'lt': {
const left = resolve_value(item.left, state);
const right = resolve_value(item.right, state);
if (typeof left !== 'number' || typeof right !== 'number') {
invalid('INVALID_CONDITION', 'Numeric comparison requires numbers');
}
return item.op === 'gt' ? left > right : left < right;
}
default: return invalid('INVALID_CONDITION', 'Unknown condition operator');
}
};
return evaluate(condition, 0);
}