chore: initial commit
This commit is contained in:
@@ -0,0 +1,732 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import test from 'node:test';
|
||||
import {tsImport} from 'tsx/esm/api';
|
||||
|
||||
const {create_workflow_manager} = await tsImport('../bot/workflow/runtime/states_manager.ts', import.meta.url);
|
||||
const {init_workflow} = await tsImport('../bot/workflow/init.ts', import.meta.url);
|
||||
const {parse_message_trigger} = await tsImport('../bot/workflow/node_action/event_node/message.ts', import.meta.url);
|
||||
|
||||
function context(ready = true) {
|
||||
const events = new EventEmitter();
|
||||
const bot = new EventEmitter();
|
||||
if (ready) bot.entity = {id: 1};
|
||||
return {
|
||||
bot_instance: bot, bot_status: 'online', function_manager: {run: async () => true}, info_manager: null,
|
||||
workflow_manager: null, bot_config: {}, logger: null, log() {},
|
||||
on: events.on.bind(events), off: events.off.bind(events),
|
||||
emit: events.emit.bind(events), events,
|
||||
};
|
||||
}
|
||||
|
||||
function definition(id = 'one', node = {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}, extra = {}) {
|
||||
return {
|
||||
schema_version: 1, id, name: id, enabled: true, start: {type: 'common', auto_start: 'never'},
|
||||
entry: 'entry', nodes: [node], edges: [],
|
||||
policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}, ...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(finish => { resolve = finish; });
|
||||
return {promise, resolve};
|
||||
}
|
||||
|
||||
function function_definition(id) {
|
||||
return definition(id, {id: 'entry', type: 'function', config: {path: 'bot', name: id, args: []}});
|
||||
}
|
||||
|
||||
const turn = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function until(check) {
|
||||
for (let index = 0; index < 100; index++) {
|
||||
if (check()) return;
|
||||
await turn();
|
||||
}
|
||||
assert.fail('Expected workflow state was not reached');
|
||||
}
|
||||
const ended = snapshot => snapshot?.ended_at !== null && snapshot?.ended_at !== undefined;
|
||||
|
||||
test('manager validates disabled graphs, duplicate ids and recursive workflow event', () => {
|
||||
const ctx = context();
|
||||
assert.throws(() => create_workflow_manager(ctx, [definition(), definition()]));
|
||||
assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined, {enabled: false, entry: 'missing'})]));
|
||||
assert.throws(() => create_workflow_manager(ctx, [definition('off', undefined,
|
||||
{enabled: false, start: {type: 'event', event: 'workflow_update'}})]));
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
});
|
||||
|
||||
test('manager validates global and definition concurrency limits before installing listeners', async () => {
|
||||
const ctx = context();
|
||||
for (const max_concurrent_runs of [0, 257, -1, 1.5, NaN, Infinity, '2']) {
|
||||
assert.throws(() => create_workflow_manager(ctx, [definition()], {max_concurrent_runs}),
|
||||
{code: 'INVALID_CONCURRENCY_LIMIT'});
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
}
|
||||
for (const max_concurrent_runs of [0, 257, -1, 1.5, '2', null]) {
|
||||
const config = definition();
|
||||
config.policy.max_concurrent_runs = max_concurrent_runs;
|
||||
assert.throws(() => create_workflow_manager(ctx, [config]), /max_concurrent_runs|invalid concurrent run limit/);
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
}
|
||||
for (const max_concurrent_runs of [1, 256]) {
|
||||
const config = definition();
|
||||
config.policy.max_concurrent_runs = max_concurrent_runs;
|
||||
const manager = create_workflow_manager(ctx, [config], {max_concurrent_runs});
|
||||
await manager.dispose();
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('manager requires spawn and resets readiness on status disconnect', async () => {
|
||||
const ctx = context(false);
|
||||
const manager = create_workflow_manager(ctx, [definition()]);
|
||||
try {
|
||||
assert.throws(() => manager.start('missing'), {code: 'WORKFLOW_NOT_FOUND'});
|
||||
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
|
||||
ctx.bot_instance.entity = {id: 1};
|
||||
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
|
||||
ctx.emit('spawn');
|
||||
const run = manager.start('one');
|
||||
assert.equal(run.status, 'started');
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
ctx.bot_status = 'disconnecting';
|
||||
ctx.emit('status_update', 'disconnecting');
|
||||
await until(() => ended(manager.get_snapshot(run.run_id)));
|
||||
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect');
|
||||
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
|
||||
ctx.bot_status = 'online';
|
||||
ctx.emit('status_update', 'online');
|
||||
assert.throws(() => manager.start('one'), {code: 'BOT_NOT_READY'});
|
||||
} finally { await manager.dispose(); }
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
});
|
||||
|
||||
test('manager queues or skips repeated definitions while distinct workflows run together', async () => {
|
||||
const ctx = context();
|
||||
const first = definition('one');
|
||||
first.policy.max_queue = 1;
|
||||
const skip = definition('skip');
|
||||
skip.policy.on_busy = 'skip';
|
||||
const manager = create_workflow_manager(ctx, [first, skip]);
|
||||
try {
|
||||
const running = manager.start('one');
|
||||
const trigger = {nested: {value: 7}};
|
||||
assert.equal(manager.start('one', trigger).status, 'queued');
|
||||
trigger.nested.value = 99;
|
||||
assert.equal(manager.start('one').status, 'skipped');
|
||||
const distinct = manager.start('skip');
|
||||
assert.equal(distinct.status, 'started');
|
||||
assert.equal(manager.start('skip').status, 'skipped');
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
await manager.stop(running.run_id);
|
||||
await until(() => manager.get_snapshots().length === 3);
|
||||
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one'
|
||||
&& snapshot.run_id !== running.run_id);
|
||||
assert.equal(ended(manager.get_snapshot(distinct.run_id)), false);
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => ended(manager.get_snapshot(queued.run_id)) && ended(manager.get_snapshot(distinct.run_id)));
|
||||
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.nested.value, 7);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('manager defaults to sixteen concurrent distinct definitions', async () => {
|
||||
const ctx = context();
|
||||
ctx.bot_instance.setMaxListeners(0);
|
||||
const configs = Array.from({length: 17}, (_, index) => definition(`workflow_${index}`));
|
||||
const manager = create_workflow_manager(ctx, configs);
|
||||
try {
|
||||
for (const config of configs.slice(0, 16)) assert.equal(manager.start(config.id).status, 'started');
|
||||
assert.equal(manager.start(configs[16].id).status, 'queued');
|
||||
assert.equal(manager.get_snapshots().length, 16);
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 16);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().length === 17
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
assert.ok(manager.get_snapshots().every(snapshot => snapshot.status === 'completed'));
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('distinct concurrent workflows keep triggers, variables and outputs independent', async () => {
|
||||
const ctx = context();
|
||||
const releases = {one: deferred(), two: deferred()};
|
||||
const calls = [];
|
||||
ctx.info_manager = {get_info: name => ({value: {owner: name}})};
|
||||
ctx.function_manager = {async run(_path, name, args, check, {signal}) {
|
||||
calls.push({name, args, signal});
|
||||
await releases[name].promise;
|
||||
check();
|
||||
return true;
|
||||
}};
|
||||
const configs = ['one', 'two'].map(id => definition(id, undefined, {
|
||||
initial_vars: {owner: id},
|
||||
nodes: [
|
||||
{id: 'entry', type: 'query', config: {source: 'info', save_as: 'saved',
|
||||
args: {name: {kind: 'ref', root: 'trigger', path: ['nested', 'owner']}}}},
|
||||
{id: 'use', type: 'function', config: {path: 'bot', name: id, args: [
|
||||
{kind: 'ref', root: 'trigger', path: ['nested']},
|
||||
{kind: 'ref', root: 'vars', path: ['owner']},
|
||||
{kind: 'ref', root: 'vars', path: ['saved']},
|
||||
{kind: 'ref', root: 'outputs', path: ['entry', 'output']},
|
||||
]}},
|
||||
],
|
||||
edges: [{id: 'use_result', source: 'entry', port: 'success', target: 'use'}],
|
||||
}));
|
||||
const manager = create_workflow_manager(ctx, configs);
|
||||
try {
|
||||
const trigger = {nested: {owner: 'one'}};
|
||||
const one = manager.start('one', trigger);
|
||||
trigger.nested.owner = 'two';
|
||||
const two = manager.start('two', trigger);
|
||||
trigger.nested.owner = 'mutated';
|
||||
assert.equal(one.status, 'started');
|
||||
assert.equal(two.status, 'started');
|
||||
assert.notEqual(one.run_id, two.run_id);
|
||||
await until(() => calls.length === 2);
|
||||
for (const call of calls) {
|
||||
assert.deepEqual(call.args, [{owner: call.name}, call.name, {owner: call.name}, {owner: call.name}]);
|
||||
assert.equal(call.signal.aborted, false);
|
||||
}
|
||||
assert.notEqual(calls[0].signal, calls[1].signal);
|
||||
releases.two.resolve();
|
||||
await until(() => ended(manager.get_snapshot(two.run_id)));
|
||||
assert.equal(ended(manager.get_snapshot(one.run_id)), false);
|
||||
releases.one.resolve();
|
||||
await until(() => ended(manager.get_snapshot(one.run_id)));
|
||||
for (const run of [one, two]) {
|
||||
const snapshot = manager.get_snapshot(run.run_id);
|
||||
assert.equal(snapshot.status, 'completed');
|
||||
const branch = snapshot.branches[0];
|
||||
assert.deepEqual(branch.trigger, {nested: {owner: snapshot.workflow_id}});
|
||||
assert.deepEqual(branch.vars, {owner: snapshot.workflow_id, saved: {owner: snapshot.workflow_id}});
|
||||
assert.deepEqual(branch.outputs.entry.output, {owner: snapshot.workflow_id});
|
||||
assert.equal(branch.outputs.use.output, true);
|
||||
}
|
||||
} finally {
|
||||
Object.values(releases).forEach(release => release.resolve());
|
||||
await manager.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('definition concurrency two overlaps two runs and queues the next instance', async () => {
|
||||
const ctx = context();
|
||||
const config = definition();
|
||||
config.policy.max_concurrent_runs = 2;
|
||||
config.policy.max_queue = 1;
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
const first = manager.start('one', {instance: 1});
|
||||
const second = manager.start('one', {instance: 2});
|
||||
assert.equal(first.status, 'started');
|
||||
assert.equal(second.status, 'started');
|
||||
assert.notEqual(first.run_id, second.run_id);
|
||||
assert.equal(manager.start('one', {instance: 3}).status, 'queued');
|
||||
assert.equal(manager.start('one', {instance: 4}).status, 'skipped');
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
await manager.stop(first.run_id);
|
||||
await until(() => manager.get_snapshots().length === 3
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
assert.equal(ended(manager.get_snapshot(second.run_id)), false);
|
||||
const queued = manager.get_snapshots().find(snapshot => ![first.run_id, second.run_id].includes(snapshot.run_id));
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
assert.equal(manager.get_snapshot(second.run_id).status, 'completed');
|
||||
assert.equal(manager.get_snapshot(queued.run_id).status, 'completed');
|
||||
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 3);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('global concurrency two queues or skips distinct definitions at capacity', async () => {
|
||||
const ctx = context();
|
||||
const skip = definition('skip');
|
||||
skip.policy.on_busy = 'skip';
|
||||
const manager = create_workflow_manager(ctx, [definition('one'), definition('two'), definition('three'), skip],
|
||||
{max_concurrent_runs: 2});
|
||||
try {
|
||||
const one = manager.start('one');
|
||||
const two = manager.start('two');
|
||||
assert.equal(one.status, 'started');
|
||||
assert.equal(two.status, 'started');
|
||||
assert.equal(manager.start('three').status, 'queued');
|
||||
assert.equal(manager.start('skip').status, 'skipped');
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
await manager.stop(one.run_id);
|
||||
await until(() => manager.get_snapshots().length === 3
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
|
||||
assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'three'));
|
||||
assert.ok(manager.get_snapshots().every(snapshot => snapshot.workflow_id !== 'skip'));
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('queue pump bypasses a busy definition without losing its earlier queued request', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition('one'), definition('occupied'), definition('two')],
|
||||
{max_concurrent_runs: 2});
|
||||
try {
|
||||
const one = manager.start('one', {instance: 1});
|
||||
const occupied = manager.start('occupied');
|
||||
assert.equal(manager.start('one', {instance: 2}).status, 'queued');
|
||||
assert.equal(manager.start('two').status, 'queued');
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
await manager.stop(occupied.run_id);
|
||||
await until(() => manager.get_snapshots().length === 3
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
const two = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two');
|
||||
assert.ok(two);
|
||||
assert.equal(manager.get_snapshots().filter(snapshot => snapshot.workflow_id === 'one').length, 1);
|
||||
assert.equal(ended(manager.get_snapshot(one.run_id)), false);
|
||||
await manager.stop(two.run_id);
|
||||
await turn();
|
||||
assert.equal(manager.get_snapshots().length, 3);
|
||||
await manager.stop(one.run_id);
|
||||
await until(() => manager.get_snapshots().length === 4
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'one'
|
||||
&& snapshot.run_id !== one.run_id);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => ended(manager.get_snapshot(queued.run_id)));
|
||||
assert.equal(manager.get_snapshot(queued.run_id).branches[0].trigger.instance, 2);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('manager bounds global queue at 256 and disconnect clears queued work', async () => {
|
||||
const ctx = context();
|
||||
const config = definition();
|
||||
config.policy.max_queue = 1000;
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
const run = manager.start('one');
|
||||
for (let index = 0; index < 256; index++) assert.equal(manager.start('one').status, 'queued');
|
||||
assert.equal(manager.start('one').status, 'skipped');
|
||||
ctx.bot_status = 'offline';
|
||||
ctx.emit('status_update', 'offline');
|
||||
await until(() => ended(manager.get_snapshot(run.run_id)));
|
||||
ctx.bot_status = 'online';
|
||||
ctx.emit('spawn');
|
||||
await turn();
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('manager validates bounded JSON triggers without invoking accessors', async () => {
|
||||
const manager = create_workflow_manager(context(), [definition()]);
|
||||
try {
|
||||
let reads = 0;
|
||||
const accessor = Object.defineProperty({}, 'value', {enumerable: true, get() { reads++; return 1; }});
|
||||
const cycle = {}; cycle.self = cycle;
|
||||
for (const trigger of [accessor, cycle, [], null, {text: 'x'.repeat(70_000)}, {value: Infinity}]) {
|
||||
assert.throws(() => manager.start('one', trigger));
|
||||
}
|
||||
assert.equal(reads, 0);
|
||||
assert.equal(manager.get_snapshots().length, 0);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('reentrant observer can launch another definition after the launching gate clears', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition(), definition('two')]);
|
||||
let result;
|
||||
const off = manager.on_update(() => {
|
||||
if (!result) result = manager.start('two', {queued: true});
|
||||
throw new Error('broken observer');
|
||||
});
|
||||
ctx.on('workflow_update', () => { throw new Error('broken context observer'); });
|
||||
try {
|
||||
const run = manager.start('one');
|
||||
assert.equal(result.status, 'queued');
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
await until(() => manager.get_snapshots().length === 2
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 2);
|
||||
assert.equal(ended(manager.get_snapshot(run.run_id)), false);
|
||||
assert.ok(manager.get_snapshots().some(snapshot => snapshot.workflow_id === 'two'));
|
||||
off();
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
const queued = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'two');
|
||||
assert.deepEqual(queued.branches[0].trigger, {queued: true});
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('terminal snapshots are readable in the final update and retained snapshots are cloned', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log',
|
||||
config: {level: 'info', message: {kind: 'literal', value: 'done'}}})]);
|
||||
let terminal;
|
||||
manager.on_update(event => {
|
||||
if (event.type === 'run_updated' && event.snapshot.ended_at !== null) {
|
||||
terminal = manager.get_snapshots().find(snapshot => snapshot.run_id === event.snapshot.run_id);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const run = manager.start('one', {value: 1});
|
||||
await until(() => terminal);
|
||||
assert.equal(terminal.status, 'completed');
|
||||
terminal.branches[0].trigger.value = 2;
|
||||
assert.equal(manager.get_snapshot(run.run_id).branches[0].trigger.value, 1);
|
||||
await manager.stop(run.run_id);
|
||||
await assert.rejects(manager.stop('unknown'), {code: 'WORKFLOW_RUN_NOT_FOUND'});
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('function dependencies bind at actual queue start and pass scoped cancellation signal', async () => {
|
||||
const ctx = context();
|
||||
const calls = [];
|
||||
const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function',
|
||||
config: {path: 'bot', name: 'chat', args: [{kind: 'literal', value: 'hello'}]}})], {max_concurrent_runs: 1});
|
||||
try {
|
||||
const first = manager.start('one');
|
||||
manager.start('function');
|
||||
ctx.function_manager = {async run(path, name, args, check, options) {
|
||||
check(); calls.push({path, name, args, signal: options.signal}); return true;
|
||||
}};
|
||||
await manager.stop(first.run_id);
|
||||
await until(() => calls.length === 1);
|
||||
assert.equal(calls[0].path, 'bot');
|
||||
assert.equal(calls[0].name, 'chat');
|
||||
assert.deepEqual(calls[0].args, ['hello']);
|
||||
assert.ok(calls[0].signal instanceof AbortSignal);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('function cancellation drains before the queued workflow starts', async () => {
|
||||
const ctx = context();
|
||||
let aborted = false;
|
||||
let drained;
|
||||
const drain = new Promise(resolve => { drained = resolve; });
|
||||
ctx.function_manager = {async run(_path, _name, _args, _check, {signal}) {
|
||||
await new Promise(resolve => signal.addEventListener('abort', () => { aborted = true; resolve(); }, {once: true}));
|
||||
await drain;
|
||||
signal.throwIfAborted();
|
||||
}};
|
||||
const manager = create_workflow_manager(ctx, [definition(), definition('function', {id: 'entry', type: 'function',
|
||||
config: {path: 'bot', name: 'chat', args: []}})], {max_concurrent_runs: 1});
|
||||
try {
|
||||
const run = manager.start('function');
|
||||
await turn();
|
||||
manager.start('one');
|
||||
let stopped = false;
|
||||
const stop = manager.stop(run.run_id).then(() => { stopped = true; });
|
||||
await until(() => aborted);
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
drained();
|
||||
await stop;
|
||||
await until(() => manager.get_snapshots().length === 2);
|
||||
} finally { drained(); await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('stopping one concurrent run preserves other signals and holds its slot until drain', async () => {
|
||||
const ctx = context();
|
||||
const releases = {one: deferred(), two: deferred()};
|
||||
const calls = [];
|
||||
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
|
||||
calls.push({name, signal});
|
||||
await releases[name].promise;
|
||||
check();
|
||||
return true;
|
||||
}};
|
||||
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
|
||||
definition('queued')], {max_concurrent_runs: 2});
|
||||
try {
|
||||
const one = manager.start('one');
|
||||
const two = manager.start('two');
|
||||
assert.equal(manager.start('queued').status, 'queued');
|
||||
await until(() => calls.length === 2);
|
||||
const first = calls.find(call => call.name === 'one');
|
||||
const second = calls.find(call => call.name === 'two');
|
||||
assert.notEqual(first.signal, second.signal);
|
||||
let stopped = false;
|
||||
const stop = manager.stop(one.run_id).then(() => { stopped = true; });
|
||||
await until(() => first.signal.aborted);
|
||||
await turn();
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(second.signal.aborted, false);
|
||||
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
|
||||
releases.one.resolve();
|
||||
await stop;
|
||||
await until(() => manager.get_snapshots().length === 3
|
||||
&& ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
assert.equal(manager.get_snapshot(one.run_id).status, 'cancelled');
|
||||
assert.equal(manager.get_snapshot(one.run_id).stop_reason, 'manual');
|
||||
assert.equal(second.signal.aborted, false);
|
||||
releases.two.resolve();
|
||||
await until(() => ended(manager.get_snapshot(two.run_id)));
|
||||
assert.equal(manager.get_snapshot(two.run_id).status, 'completed');
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
assert.equal(manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'queued').status, 'completed');
|
||||
} finally {
|
||||
Object.values(releases).forEach(release => release.resolve());
|
||||
await manager.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('dispose aborts all concurrent runs and awaits every deferred callback drain', async () => {
|
||||
const ctx = context();
|
||||
const releases = {one: deferred(), two: deferred()};
|
||||
const calls = [];
|
||||
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
|
||||
calls.push({name, signal});
|
||||
await releases[name].promise;
|
||||
check();
|
||||
return true;
|
||||
}};
|
||||
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
|
||||
definition('queued')], {max_concurrent_runs: 2});
|
||||
try {
|
||||
const one = manager.start('one');
|
||||
const two = manager.start('two');
|
||||
assert.equal(manager.start('queued').status, 'queued');
|
||||
await until(() => calls.length === 2);
|
||||
let disposed = false;
|
||||
const dispose = manager.dispose();
|
||||
assert.equal(manager.dispose(), dispose);
|
||||
const done = dispose.then(() => { disposed = true; });
|
||||
await until(() => calls.every(call => call.signal.aborted));
|
||||
assert.equal(disposed, false);
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'});
|
||||
releases.one.resolve();
|
||||
await until(() => ended(manager.get_snapshot(one.run_id)));
|
||||
assert.equal(disposed, false);
|
||||
assert.equal(ended(manager.get_snapshot(two.run_id)), false);
|
||||
releases.two.resolve();
|
||||
await done;
|
||||
assert.equal(disposed, true);
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
for (const run of [one, two]) {
|
||||
assert.ok(ended(manager.get_snapshot(run.run_id)));
|
||||
assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled');
|
||||
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disposed');
|
||||
}
|
||||
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
|
||||
assert.equal(ctx.bot_instance.listenerCount('end'), 0);
|
||||
} finally {
|
||||
Object.values(releases).forEach(release => release.resolve());
|
||||
await manager.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect cancels every concurrent run and clears queued definitions across respawn', async () => {
|
||||
const ctx = context();
|
||||
const releases = {one: deferred(), two: deferred()};
|
||||
const calls = [];
|
||||
ctx.function_manager = {async run(_path, name, _args, check, {signal}) {
|
||||
calls.push({name, signal});
|
||||
await releases[name].promise;
|
||||
check();
|
||||
return true;
|
||||
}};
|
||||
const manager = create_workflow_manager(ctx, [function_definition('one'), function_definition('two'),
|
||||
definition('queued')], {max_concurrent_runs: 2});
|
||||
try {
|
||||
const one = manager.start('one');
|
||||
const two = manager.start('two');
|
||||
assert.equal(manager.start('queued').status, 'queued');
|
||||
assert.equal(manager.start('one').status, 'queued');
|
||||
await until(() => calls.length === 2);
|
||||
ctx.bot_status = 'offline';
|
||||
ctx.emit('status_update', 'offline');
|
||||
await until(() => calls.every(call => call.signal.aborted));
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot)));
|
||||
ctx.bot_status = 'online';
|
||||
ctx.emit('status_update', 'online');
|
||||
assert.throws(() => manager.start('queued'), {code: 'BOT_NOT_READY'});
|
||||
ctx.emit('spawn');
|
||||
releases.one.resolve();
|
||||
releases.two.resolve();
|
||||
await until(() => manager.get_snapshots().every(ended));
|
||||
await turn();
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
assert.equal(calls.length, 2);
|
||||
for (const run of [one, two]) {
|
||||
assert.equal(manager.get_snapshot(run.run_id).status, 'cancelled');
|
||||
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'disconnect');
|
||||
}
|
||||
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
|
||||
const fresh = manager.start('queued');
|
||||
assert.equal(fresh.status, 'started');
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => ended(manager.get_snapshot(fresh.run_id)));
|
||||
assert.equal(manager.get_snapshot(fresh.run_id).status, 'completed');
|
||||
} finally {
|
||||
Object.values(releases).forEach(release => release.resolve());
|
||||
await manager.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('info query uses the info adapter and unknown sources fail predictably', async () => {
|
||||
const ctx = context();
|
||||
ctx.info_manager = {get_info: name => name === 'health' ? {value: {health: 20}} : undefined};
|
||||
const manager = create_workflow_manager(ctx, ['info', 'missing'].map(source => definition(source,
|
||||
{id: 'entry', type: 'query', config: {source, args: {name: {kind: 'literal', value: 'health'}}}})));
|
||||
try {
|
||||
const run = manager.start('info');
|
||||
await until(() => ended(manager.get_snapshot(run.run_id)));
|
||||
assert.deepEqual(manager.get_snapshot(run.run_id).branches[0].last_result.output, {health: 20});
|
||||
const bad = manager.start('missing');
|
||||
await until(() => ended(manager.get_snapshot(bad.run_id)));
|
||||
assert.equal(manager.get_snapshot(bad.run_id).error.code, 'UNKNOWN_QUERY_SOURCE');
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('info query metadata validation preserves the argument error code', async () => {
|
||||
const ctx = context();
|
||||
ctx.info_manager = {get_info: () => ({value: 20})};
|
||||
const configs = [{}, {name: {kind: 'literal', value: ''}}, {name: {kind: 'literal', value: 1}}]
|
||||
.map((args, index) => definition(`invalid_${index}`, {id: 'entry', type: 'query', config: {source: 'info', args}}));
|
||||
const manager = create_workflow_manager(ctx, configs);
|
||||
try {
|
||||
const runs = configs.map(config => manager.start(config.id));
|
||||
await until(() => runs.every(run => ended(manager.get_snapshot(run.run_id))));
|
||||
for (const run of runs) assert.equal(manager.get_snapshot(run.run_id).error.code, 'INVALID_QUERY_ARGUMENTS');
|
||||
} finally {await manager.dispose();}
|
||||
});
|
||||
|
||||
test('common spawn, message conditions, arbitrary events and any recursion filter work', async () => {
|
||||
const ctx = context(false);
|
||||
const auto = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}});
|
||||
const event = definition('message', undefined, {start: {type: 'event', event: 'message', condition: {
|
||||
op: 'eq', left: {kind: 'ref', root: 'trigger', path: ['username']}, right: {kind: 'literal', value: 'Alice'},
|
||||
}}});
|
||||
const custom = definition('custom', undefined, {start: {type: 'event', event: 'someCustomEvent'}});
|
||||
const any = definition('any', undefined, {start: {type: 'event', event: 'any'}});
|
||||
const manager = create_workflow_manager(ctx, [auto, event, custom, any]);
|
||||
try {
|
||||
ctx.bot_instance.entity = {id: 1};
|
||||
ctx.emit('spawn');
|
||||
assert.equal(manager.get_snapshots()[0].workflow_id, 'auto');
|
||||
ctx.emit('message', {text: '<Bob> no'}, 'chat');
|
||||
ctx.emit('message', {text: '<Alice> hello'}, 'chat');
|
||||
ctx.emit('someCustomEvent', 1, {safe: true}, ctx.bot_instance);
|
||||
ctx.emit('any', 'workflow_update', {loop: true});
|
||||
ctx.emit('any', 'chat', 'Alice', 'hello');
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 4);
|
||||
assert.equal(manager.get_snapshots().length, 4);
|
||||
assert.ok(manager.get_snapshots().every(snapshot => !ended(snapshot)));
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshots().length === 4 && manager.get_snapshots().every(ended));
|
||||
const message = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'message');
|
||||
assert.equal(message.branches[0].trigger.message, 'hello');
|
||||
const custom_trigger = manager.get_snapshots().find(snapshot => snapshot.workflow_id === 'custom').branches[0].trigger;
|
||||
assert.deepEqual(custom_trigger.args, [1, {safe: true}, null]);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('message adapter reads bounded ChatMessage JSON and rejects malformed objects', () => {
|
||||
class ChatMessage { constructor() { this.json = {text: '<Alice> hello'}; } }
|
||||
assert.deepEqual(parse_message_trigger([new ChatMessage(), 'chat', 'uuid', true]), {
|
||||
event: 'message', username: 'Alice', message: 'hello', plainText: '<Alice> hello',
|
||||
position: 'chat', sender: 'uuid', verified: true,
|
||||
});
|
||||
assert.equal(parse_message_trigger([{text: 'x'.repeat(70_000)}]), undefined);
|
||||
const cyclic = {}; cyclic.text = cyclic;
|
||||
assert.equal(parse_message_trigger([cyclic]), undefined);
|
||||
});
|
||||
|
||||
test('initially ready common start and timer lifetime are disposed', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout']});
|
||||
const ctx = context();
|
||||
const config = definition('auto', undefined, {start: {type: 'common', auto_start: 'spawn'}});
|
||||
const manager = create_workflow_manager(ctx, [config,
|
||||
definition('timer', undefined, {start: {type: 'timer', interval_ms: 10}})]);
|
||||
await turn();
|
||||
assert.equal(manager.get_snapshots()[0].workflow_id, 'auto');
|
||||
t.mock.timers.tick(25);
|
||||
await turn();
|
||||
await manager.dispose();
|
||||
const count = manager.get_snapshots().length;
|
||||
t.mock.timers.tick(25);
|
||||
assert.equal(manager.get_snapshots().length, count);
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
assert.equal(ctx.bot_instance.listenerCount('physicsTick'), 0);
|
||||
assert.equal(ctx.bot_instance.listenerCount('end'), 0);
|
||||
});
|
||||
|
||||
test('init assigns before subscriptions and rolls back all listeners on setup failure', async () => {
|
||||
const ctx = context();
|
||||
ctx.bot_config = {config_revision: 1};
|
||||
const original_on = ctx.on;
|
||||
let observed = false;
|
||||
ctx.on = (event, callback) => {
|
||||
assert.ok(ctx.workflow_manager);
|
||||
observed = true;
|
||||
original_on(event, callback);
|
||||
if (event === 'someCustomEvent') throw new Error('listener install failed');
|
||||
};
|
||||
await assert.rejects(init_workflow(ctx, [definition('bad', undefined,
|
||||
{start: {type: 'event', event: 'someCustomEvent'}})]), /listener install failed/);
|
||||
assert.equal(observed, true);
|
||||
assert.equal(ctx.workflow_manager, null);
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
});
|
||||
|
||||
test('dispose during first-node logging awaits launched run cleanup', async () => {
|
||||
const ctx = context();
|
||||
let dispose;
|
||||
let manager;
|
||||
ctx.logger = {info: message => ({message})};
|
||||
ctx.log = () => { dispose = manager.dispose(); };
|
||||
manager = create_workflow_manager(ctx, [definition('one', {id: 'entry', type: 'log',
|
||||
config: {level: 'info', message: {kind: 'literal', value: 'dispose'}}})]);
|
||||
const run = manager.start('one');
|
||||
await until(() => dispose);
|
||||
await dispose;
|
||||
assert.ok(ended(manager.get_snapshot(run.run_id)));
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_MANAGER_DISPOSED'});
|
||||
});
|
||||
|
||||
test('manager disables code by default and trusted host can explicitly enable it', async () => {
|
||||
const config = definition('code', {id: 'entry', type: 'code', config: {source: 'return 42;'}});
|
||||
const blocked = create_workflow_manager(context(), [config]);
|
||||
const trusted = create_workflow_manager(context(), [config], {allow_code: true});
|
||||
try {
|
||||
const first = blocked.start('code');
|
||||
const second = trusted.start('code');
|
||||
await until(() => ended(blocked.get_snapshot(first.run_id)));
|
||||
assert.equal(blocked.get_snapshot(first.run_id).error.code, 'CODE_EXECUTION_DISABLED');
|
||||
await new Promise(resolve => trusted.on_update(event => {
|
||||
if (event.type === 'run_updated' && event.snapshot.ended_at !== null) resolve();
|
||||
}));
|
||||
assert.equal(trusted.get_snapshot(second.run_id).branches[0].last_result.output, 42);
|
||||
} finally { await blocked.dispose(); await trusted.dispose(); }
|
||||
});
|
||||
|
||||
test('manager retains only the latest 20 completed runs without evicting active runs', async () => {
|
||||
const manager = create_workflow_manager(context(), [definition('one', {id: 'entry', type: 'log',
|
||||
config: {level: 'info', message: {kind: 'literal', value: 'done'}}}),
|
||||
definition('active', {id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1000}})]);
|
||||
const completed_ids = [];
|
||||
try {
|
||||
const active = manager.start('active');
|
||||
for (let index = 0; index < 25; index++) {
|
||||
const run = manager.start('one');
|
||||
completed_ids.push(run.run_id);
|
||||
await until(() => ended(manager.get_snapshot(run.run_id)));
|
||||
await turn();
|
||||
}
|
||||
const snapshots = manager.get_snapshots();
|
||||
assert.equal(snapshots.length, 21);
|
||||
assert.deepEqual(snapshots.filter(ended).map(run => run.run_id), completed_ids.slice(-20));
|
||||
for (const id of completed_ids.slice(0, 5)) assert.equal(manager.get_snapshot(id), undefined);
|
||||
assert.equal(manager.get_snapshot(active.run_id).status, 'running');
|
||||
await manager.stop(active.run_id);
|
||||
assert.equal(manager.get_snapshots().length, 20);
|
||||
assert.equal(manager.get_snapshot(completed_ids[5]), undefined);
|
||||
assert.equal(manager.get_snapshot(active.run_id).status, 'cancelled');
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
Reference in New Issue
Block a user