66 lines
3.9 KiB
TypeScript
66 lines
3.9 KiB
TypeScript
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);
|
|
}
|
|
}
|