chore: initial commit
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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 {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
|
||||
const {validate_workflow} = await tsImport('../bot/workflow/runtime/validate.ts', import.meta.url);
|
||||
const turn = () => new Promise(resolve => setImmediate(resolve));
|
||||
async function until(check) {
|
||||
for (let i = 0; i < 100; i++) { if (check()) return; await turn(); }
|
||||
assert.fail('Expected runtime state');
|
||||
}
|
||||
function context(ready = true) {
|
||||
const events = new EventEmitter();
|
||||
const bot = new EventEmitter();
|
||||
if (ready) bot.entity = {id: 1};
|
||||
return {events, bot_instance: bot, bot_status: 'online', info_manager: null,
|
||||
function_manager: {run: async () => true}, logger: null, log() {},
|
||||
on: events.on.bind(events), off: events.off.bind(events), emit: events.emit.bind(events)};
|
||||
}
|
||||
function definition(id = 'one', start = {type: 'common', auto_start: 'never'}) {
|
||||
return {schema_version: 1, id, name: id, enabled: true, start, entry: 'entry',
|
||||
nodes: [{id: 'entry', type: 'sleep', config: {mode: 'ticks', value: 1}}], edges: [],
|
||||
policy: {on_busy: 'queue', max_queue: 10, max_steps: 100, timeout_ms: 5000}};
|
||||
}
|
||||
const state = (manager, id = 'one') => manager.get_runtime_snapshot().workflows.find(item => item.workflow_id === id);
|
||||
let sequence = 0;
|
||||
async function command(manager, action, extra = {}) {
|
||||
const accepted = manager.control({command_id: `cmd_${++sequence}`, action, workflow_id: 'one', ...extra});
|
||||
assert.equal(accepted.state, 'accepted');
|
||||
await until(() => manager.get_command(accepted.command_id)?.state !== 'accepted');
|
||||
return manager.get_command(accepted.command_id);
|
||||
}
|
||||
|
||||
test('apply precompiles all graphs and revisions are immutable, ordered and idempotent', async () => {
|
||||
const ctx = context();
|
||||
const original = definition('one', {type: 'event', event: 'hello'});
|
||||
const manager = create_workflow_manager(ctx, [original], {source_config_revision: 4});
|
||||
try {
|
||||
const run = manager.start('one');
|
||||
manager.start('one');
|
||||
const before = state(manager);
|
||||
const invalid = definition('bad'); invalid.entry = 'missing';
|
||||
await assert.rejects(manager.apply([{...original, name: 'new'}, invalid], 5));
|
||||
assert.deepEqual(state(manager), before);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
await manager.apply([structuredClone(original)], 4);
|
||||
assert.deepEqual(state(manager), before);
|
||||
await assert.rejects(manager.apply([original], 3), {code: 'STALE_SOURCE_REVISION'});
|
||||
await assert.rejects(manager.apply([{...original, name: 'new'}], 4), {code: 'SOURCE_REVISION_CONFLICT'});
|
||||
assert.equal(manager.get_snapshot(run.run_id).ended_at, null);
|
||||
await manager.apply([original], 5);
|
||||
assert.equal(manager.get_runtime_snapshot().source_config_revision, 5);
|
||||
assert.deepEqual(state(manager), before);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('unversioned baseline zero is optional and explicit zero conflicts with changed content', async () => {
|
||||
const original = definition();
|
||||
const manager = create_workflow_manager(context(), [original]);
|
||||
try {
|
||||
await manager.apply([original], 0);
|
||||
await assert.rejects(manager.apply([], 0), {code: 'SOURCE_REVISION_CONFLICT'});
|
||||
await manager.apply([]);
|
||||
await manager.apply([original]);
|
||||
assert.equal(state(manager).generation, 2);
|
||||
for (const revision of [-1, 1.5, NaN, Infinity, '1']) {
|
||||
await assert.rejects(manager.apply([original], revision), {code: 'INVALID_SOURCE_REVISION'});
|
||||
}
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('changed and deleted workflows detach triggers, discard queues and await cancellation drain', async () => {
|
||||
const ctx = context();
|
||||
let release;
|
||||
const drain = new Promise(resolve => { release = resolve; });
|
||||
let signal;
|
||||
ctx.function_manager.run = async (_path, _name, _args, check, options) => {
|
||||
signal = options.signal;
|
||||
await drain;
|
||||
check(); return true;
|
||||
};
|
||||
const original = definition('one', {type: 'event', event: 'hello'});
|
||||
original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}];
|
||||
const other = definition('other');
|
||||
const manager = create_workflow_manager(ctx, [original, other]);
|
||||
try {
|
||||
const run = manager.start('one');
|
||||
manager.start('one');
|
||||
manager.start('other');
|
||||
await until(() => signal);
|
||||
let applied = false;
|
||||
const applying = manager.apply([{...original, name: 'new'}, other], 1).then(() => { applied = true; });
|
||||
await until(() => signal.aborted);
|
||||
assert.equal(applied, false);
|
||||
assert.equal(state(manager).trigger_state, 'reloading');
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_RELOADING'});
|
||||
release();
|
||||
await applying;
|
||||
assert.equal(state(manager).generation, 2);
|
||||
assert.equal(state(manager, 'other').generation, 1);
|
||||
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'updated');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
await manager.apply([other], 2);
|
||||
assert.equal(state(manager), undefined);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
await manager.apply([original, other], 3);
|
||||
assert.equal(state(manager).generation, 3);
|
||||
assert.equal(manager.get_snapshots().filter(item => item.workflow_id === 'one').length, 1);
|
||||
} finally { release(); await manager.dispose(); }
|
||||
});
|
||||
|
||||
for (const action of ['apply', 'reload']) {
|
||||
for (const type of ['event', 'timer']) {
|
||||
test(`${action} keeps the ${type} workflow blocked across reconnect until cancellation drains`, async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
|
||||
const ctx = context();
|
||||
let release;
|
||||
const drain = new Promise(resolve => { release = resolve; });
|
||||
const calls = [];
|
||||
ctx.function_manager.run = async (_path, name, _args, check, {signal}) => {
|
||||
calls.push({name, signal});
|
||||
if (calls.length === 1) await drain;
|
||||
check(); return true;
|
||||
};
|
||||
const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100};
|
||||
const original = definition('one', start);
|
||||
original.policy.max_concurrent_runs = 2;
|
||||
original.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'old', args: []}}];
|
||||
const replacement = structuredClone(original);
|
||||
replacement.nodes[0].config.name = 'new';
|
||||
const manager = create_workflow_manager(ctx, [original], {max_concurrent_runs: 2});
|
||||
try {
|
||||
const old = manager.start('one');
|
||||
await until(() => calls.length === 1);
|
||||
let finished = false;
|
||||
const transition = action === 'apply' ? manager.apply([replacement], 1)
|
||||
: command(manager, 'reload', {expected_generation: 1});
|
||||
const done = transition.then(result => { finished = true; return result; });
|
||||
await until(() => calls[0].signal.aborted);
|
||||
ctx.emit('end');
|
||||
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
|
||||
ctx.emit('spawn');
|
||||
assert.equal(finished, false);
|
||||
assert.equal(state(manager).generation, 1);
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.equal(state(manager).next_fire_at, null);
|
||||
ctx.emit('hello');
|
||||
t.mock.timers.tick(300);
|
||||
await turn();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
release();
|
||||
const result = await done;
|
||||
if (action === 'reload') assert.equal(result.state, 'completed');
|
||||
assert.equal(state(manager).generation, 2);
|
||||
assert.deepEqual(state(manager).active_run_ids, []);
|
||||
assert.equal(manager.get_snapshot(old.run_id).stop_reason, action === 'apply' ? 'updated' : 'reloaded');
|
||||
if (type === 'event') {
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
ctx.emit('hello');
|
||||
} else {
|
||||
assert.equal(state(manager).next_fire_at, 1400);
|
||||
t.mock.timers.tick(100);
|
||||
}
|
||||
await until(() => calls.length === 2);
|
||||
assert.equal(calls[1].name, action === 'apply' ? 'new' : 'old');
|
||||
await until(() => state(manager).active_run_ids.length === 0);
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
} finally { release(); await manager.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
test(`${action} leaves the old entry fenced with an error when draining fails`, async () => {
|
||||
const ctx = context();
|
||||
const original = definition('one', {type: 'event', event: 'hello'});
|
||||
const replacement = {...original, name: 'new'};
|
||||
const manager = create_workflow_manager(ctx, [original]);
|
||||
const drain = manager.drain;
|
||||
manager.drain = async () => { throw Object.assign(new Error('Drain failed'), {code: 'DRAIN_FAILED'}); };
|
||||
try {
|
||||
if (action === 'apply') await assert.rejects(manager.apply([replacement], 1), {code: 'DRAIN_FAILED'});
|
||||
else assert.equal((await command(manager, 'reload')).error.code, 'DRAIN_FAILED');
|
||||
assert.equal(state(manager).generation, 1);
|
||||
assert.equal(state(manager).trigger_state, 'error');
|
||||
assert.equal(state(manager).last_error.code, 'DRAIN_FAILED');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
ctx.emit('end');
|
||||
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
|
||||
ctx.emit('spawn');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
|
||||
manager.drain = drain;
|
||||
if (action === 'apply') await manager.apply([replacement], 1);
|
||||
else assert.equal((await command(manager, 'reload')).state, 'completed');
|
||||
assert.equal(state(manager).generation, 2);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
} finally { manager.drain = drain; await manager.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('common spawn fires once per bot connection and manual stop survives repeated spawn', async () => {
|
||||
const ctx = context(false);
|
||||
const config = definition('one', {type: 'common', auto_start: 'spawn'});
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
ctx.bot_instance.entity = {id: 1};
|
||||
ctx.emit('spawn'); ctx.emit('spawn');
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
await command(manager, 'stop');
|
||||
ctx.emit('spawn');
|
||||
await manager.apply([structuredClone(config)], 1);
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
ctx.emit('end'); ctx.emit('spawn');
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
ctx.emit('instance_created');
|
||||
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
|
||||
ctx.emit('spawn');
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('event once only exhausts on acceptance, detaches before reentrant events, and can restart', async () => {
|
||||
const ctx = context();
|
||||
const config = definition('one', {type: 'event', event: 'hello', mode: 'once'});
|
||||
config.policy.on_busy = 'skip';
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
const occupied = manager.start('one');
|
||||
ctx.emit('hello');
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
assert.equal(state(manager).trigger_state, 'listening');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
await manager.stop(occupied.run_id);
|
||||
const off = manager.on_update(event => { if (event.type === 'run_updated') ctx.emit('hello'); });
|
||||
ctx.emit('hello');
|
||||
off();
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
assert.equal(state(manager).trigger_state, 'exhausted');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.equal(manager.get_snapshots().length, 2);
|
||||
assert.equal((await command(manager, 'trigger_start')).state, 'completed');
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
assert.equal((await command(manager, 'trigger_start')).state, 'completed');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('queued event acceptance counts and trigger_stop leaves existing run and queue intact', async () => {
|
||||
const ctx = context();
|
||||
const config = definition('one', {type: 'event', event: 'hello', mode: 'once'});
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
const run = manager.start('one');
|
||||
ctx.emit('hello');
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
assert.equal(state(manager).queued_count, 1);
|
||||
await command(manager, 'trigger_stop');
|
||||
assert.equal(state(manager).queued_count, 1);
|
||||
assert.equal(manager.get_snapshot(run.run_id).ended_at, null);
|
||||
assert.equal((await command(manager, 'stop_run', {run_id: run.run_id})).state, 'completed');
|
||||
await until(() => manager.get_snapshots().length === 2);
|
||||
assert.equal(state(manager).trigger_state, 'stopped');
|
||||
assert.equal(state(manager).active_run_ids.length, 1);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('once event ignores an outer emitter callback already captured before nested exhaustion', async () => {
|
||||
const ctx = context();
|
||||
let nested = false;
|
||||
ctx.on('hello', () => {
|
||||
if (!nested) { nested = true; ctx.emit('hello'); }
|
||||
});
|
||||
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', mode: 'once'})]);
|
||||
try {
|
||||
ctx.emit('hello');
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
assert.equal(manager.get_snapshots().length, 1);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('concurrent applies and controls execute in submission order with revision checks after drain', async () => {
|
||||
const ctx = context();
|
||||
const original = definition();
|
||||
const manager = create_workflow_manager(ctx, [original]);
|
||||
try {
|
||||
const updated = {...original, name: 'version_2'};
|
||||
const applying = manager.apply([updated], 2);
|
||||
const accepted = manager.control({command_id: 'after_apply', action: 'reload', workflow_id: 'one', expected_generation: 2});
|
||||
const stale = manager.apply([original], 1);
|
||||
await applying;
|
||||
await assert.rejects(stale, {code: 'STALE_SOURCE_REVISION'});
|
||||
await until(() => manager.get_command(accepted.command_id).state !== 'accepted');
|
||||
assert.equal(manager.get_command(accepted.command_id).state, 'completed');
|
||||
assert.equal(state(manager).generation, 3);
|
||||
assert.equal(manager.get_runtime_snapshot().source_config_revision, 2);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('offline apply stores new definitions and waits for a new connection to install auto triggers', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
|
||||
const ctx = context();
|
||||
const config = definition('one', {type: 'event', event: 'old'});
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
ctx.emit('end');
|
||||
await manager.apply([{...config, start: {type: 'timer', interval_ms: 100}}], 1);
|
||||
assert.equal(ctx.events.listenerCount('old'), 0);
|
||||
assert.equal(state(manager).trigger_state, 'stopped');
|
||||
assert.equal(state(manager).next_fire_at, null);
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(manager.get_snapshots().length, 0);
|
||||
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
|
||||
ctx.emit('spawn');
|
||||
assert.equal(state(manager).trigger_state, 'scheduled');
|
||||
assert.equal(state(manager).next_fire_at, 2100);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('timers count accepted runs, retry skips, preserve schedules on equal apply, and reset on manual start', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
|
||||
const ctx = context();
|
||||
const config = definition('one', {type: 'timer', interval_ms: 100, max_runs: 2});
|
||||
config.policy.on_busy = 'skip';
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
assert.equal(state(manager).next_fire_at, 1100);
|
||||
t.mock.timers.tick(100);
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
t.mock.timers.tick(100);
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
assert.equal(state(manager).next_fire_at, 1300);
|
||||
await manager.apply([structuredClone(config)], 1);
|
||||
assert.equal(state(manager).next_fire_at, 1300);
|
||||
const run = state(manager).active_run_ids[0];
|
||||
await manager.stop(run);
|
||||
t.mock.timers.tick(100);
|
||||
assert.equal(state(manager).accepted_count, 2);
|
||||
assert.equal(state(manager).trigger_state, 'exhausted');
|
||||
assert.equal(state(manager).next_fire_at, null);
|
||||
await command(manager, 'trigger_start');
|
||||
const scheduled = state(manager).next_fire_at;
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
await command(manager, 'trigger_start');
|
||||
assert.equal(state(manager).next_fire_at, scheduled);
|
||||
await command(manager, 'trigger_stop');
|
||||
await manager.apply([config], 2);
|
||||
assert.equal(state(manager).trigger_state, 'stopped');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('once timer queues one run and disconnect destroys all event listeners and timers until a new bot spawns', async t => {
|
||||
t.mock.timers.enable({apis: ['setTimeout', 'Date'], now: 1000});
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition('one', {type: 'timer', interval_ms: 100, mode: 'once'}),
|
||||
definition('event', {type: 'event', event: 'hello'})]);
|
||||
try {
|
||||
manager.start('one');
|
||||
t.mock.timers.tick(100);
|
||||
assert.equal(state(manager).queued_count, 1);
|
||||
assert.equal(state(manager).accepted_count, 1);
|
||||
assert.equal(state(manager).trigger_state, 'exhausted');
|
||||
ctx.emit('end');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.equal(state(manager).next_fire_at, null);
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
t.mock.timers.tick(500);
|
||||
ctx.emit('spawn');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
ctx.bot_instance = Object.assign(new EventEmitter(), {entity: {id: 2}});
|
||||
ctx.emit('spawn');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
assert.equal(state(manager).next_fire_at, 1700);
|
||||
await manager.dispose();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(ctx.events.eventNames().length, 0);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('auto_start false and unavailable bot preserve stopped trigger state', async () => {
|
||||
const ctx = context(false);
|
||||
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello', auto_start: false})]);
|
||||
try {
|
||||
const failed = await command(manager, 'trigger_start');
|
||||
assert.equal(failed.state, 'failed');
|
||||
assert.equal(failed.error.code, 'BOT_NOT_READY');
|
||||
assert.equal(state(manager).trigger_state, 'stopped');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
assert.equal((await command(manager, 'run_once')).error.code, 'BOT_NOT_READY');
|
||||
ctx.bot_instance.entity = {id: 1}; ctx.emit('spawn');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
await command(manager, 'trigger_start');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 1);
|
||||
await command(manager, 'stop');
|
||||
ctx.emit('spawn');
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('controls return accepted before execution, deduplicate fingerprints and enforce generations', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition()]);
|
||||
try {
|
||||
const request = {command_id: 'once', action: 'run_once', workflow_id: 'one', input: {nested: {x: 1}}};
|
||||
const accepted = manager.control(request);
|
||||
assert.equal(accepted.state, 'accepted');
|
||||
assert.equal(manager.get_snapshots().length, 0);
|
||||
assert.deepEqual(manager.control(structuredClone(request)), accepted);
|
||||
assert.throws(() => manager.control({...request, input: {x: 2}}), {code: 'COMMAND_ID_CONFLICT'});
|
||||
request.input.nested.x = 2;
|
||||
await until(() => manager.get_command('once').state === 'completed');
|
||||
const result = manager.get_command('once');
|
||||
assert.equal(result.result.status, 'started');
|
||||
await until(() => ctx.bot_instance.listenerCount('physicsTick') === 1);
|
||||
ctx.bot_instance.emit('physicsTick');
|
||||
await until(() => manager.get_snapshot(result.result.run_id).ended_at !== null);
|
||||
assert.equal(manager.get_snapshot(result.result.run_id).branches[0].trigger.nested.x, 1);
|
||||
const stale = await command(manager, 'reload', {expected_generation: 99});
|
||||
assert.equal(stale.error.code, 'WORKFLOW_GENERATION_CONFLICT');
|
||||
assert.equal(state(manager).generation, 1);
|
||||
assert.equal((await command(manager, 'trigger_start')).error.code, 'INVALID_TRIGGER_TYPE');
|
||||
assert.equal((await command(manager, 'stop_run', {run_id: 'missing'})).error.code, 'WORKFLOW_RUN_NOT_FOUND');
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('stop clears workflow queue and runs while stop_run leaves the trigger running', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition('one', {type: 'event', event: 'hello'})]);
|
||||
try {
|
||||
const first = manager.start('one'); manager.start('one');
|
||||
await command(manager, 'stop_run', {run_id: first.run_id});
|
||||
assert.equal(state(manager).trigger_state, 'listening');
|
||||
await until(() => state(manager).active_run_ids.length === 1);
|
||||
ctx.emit('hello');
|
||||
await command(manager, 'stop');
|
||||
assert.equal(state(manager).trigger_state, 'stopped');
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
assert.equal(state(manager).active_run_ids.length, 0);
|
||||
assert.equal(ctx.events.listenerCount('hello'), 0);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('workflow stop rejects direct starts during deferred cancellation drain', async () => {
|
||||
const ctx = context();
|
||||
let release;
|
||||
const drain = new Promise(resolve => { release = resolve; });
|
||||
let signal;
|
||||
ctx.function_manager.run = async (_path, _name, _args, check, options) => {
|
||||
signal = options.signal; await drain; check(); return true;
|
||||
};
|
||||
const config = definition();
|
||||
config.nodes = [{id: 'entry', type: 'function', config: {path: 'bot', name: 'chat', args: []}}];
|
||||
const manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
manager.start('one'); await until(() => signal);
|
||||
const accepted = manager.control({command_id: 'draining_stop', action: 'stop', workflow_id: 'one'});
|
||||
await until(() => signal.aborted);
|
||||
assert.equal(manager.get_command(accepted.command_id).state, 'accepted');
|
||||
assert.throws(() => manager.start('one'), {code: 'WORKFLOW_STOPPING'});
|
||||
release();
|
||||
await until(() => manager.get_command(accepted.command_id).state === 'completed');
|
||||
assert.equal(state(manager).active_run_ids.length, 0);
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
} finally { release(); await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('reload uses the latest stored graph, cancels and drains, then resets generation and trigger', async () => {
|
||||
const ctx = context();
|
||||
const original = definition('one', {type: 'event', event: 'old'});
|
||||
const manager = create_workflow_manager(ctx, [original]);
|
||||
try {
|
||||
await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1);
|
||||
const run = manager.start('one'); manager.start('one');
|
||||
const result = await command(manager, 'reload', {expected_generation: 2});
|
||||
assert.equal(result.result.generation, 3);
|
||||
assert.equal(manager.get_snapshot(run.run_id).stop_reason, 'reloaded');
|
||||
assert.equal(state(manager).queued_count, 0);
|
||||
assert.equal(state(manager).accepted_count, 0);
|
||||
assert.equal(ctx.events.listenerCount('old'), 0);
|
||||
assert.equal(ctx.events.listenerCount('new'), 1);
|
||||
assert.equal(manager.get_runtime_snapshot().source_config_revision, 1);
|
||||
await manager.apply([{...original, start: {type: 'event', event: 'new'}}], 1);
|
||||
assert.equal(state(manager).generation, 3);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('a workflow can submit its own stop or reload without waiting on its cancellation drain', async () => {
|
||||
for (const action of ['stop', 'reload']) {
|
||||
const ctx = context();
|
||||
const config = definition();
|
||||
config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: action, args: []}}];
|
||||
let manager;
|
||||
ctx.function_manager.run = async () => {
|
||||
assert.equal(manager.control({command_id: 'self', action, workflow_id: 'one'}).state, 'accepted');
|
||||
return true;
|
||||
};
|
||||
manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
manager.start('one');
|
||||
await until(() => manager.get_command('self')?.state === 'completed');
|
||||
assert.equal(state(manager).generation, action === 'reload' ? 2 : 1);
|
||||
assert.equal(state(manager).active_run_ids.length, 0);
|
||||
} finally { await manager.dispose(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('self reload loops are limited to ten reloads within ten seconds', async t => {
|
||||
t.mock.timers.enable({apis: ['Date'], now: 1000});
|
||||
const ctx = context();
|
||||
const config = definition('one', {type: 'common', auto_start: 'spawn'});
|
||||
config.nodes = [{id: 'entry', type: 'function', config: {path: 'workflow', name: 'reload', args: []}}];
|
||||
let manager;
|
||||
let count = 0;
|
||||
ctx.function_manager.run = async () => {
|
||||
manager.control({command_id: `self_${++count}`, action: 'reload', workflow_id: 'one'});
|
||||
return true;
|
||||
};
|
||||
manager = create_workflow_manager(ctx, [config]);
|
||||
try {
|
||||
await until(() => manager.get_command('self_11')?.state === 'failed');
|
||||
assert.equal(manager.get_command('self_11').error.code, 'WORKFLOW_RELOAD_RATE_LIMIT');
|
||||
assert.equal(state(manager).generation, 11);
|
||||
assert.equal(count, 11);
|
||||
t.mock.timers.tick(10_000);
|
||||
assert.equal((await command(manager, 'reload')).state, 'completed');
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('pending controls are bounded at 32 and completed command history at 100', async () => {
|
||||
const manager = create_workflow_manager(context(), [definition()]);
|
||||
try {
|
||||
for (let i = 0; i < 32; i++) manager.control({command_id: `pending_${i}`, action: 'trigger_stop', workflow_id: 'one'});
|
||||
assert.throws(() => manager.control({command_id: 'overflow', action: 'stop', workflow_id: 'one'}),
|
||||
{code: 'WORKFLOW_CONTROL_BUSY'});
|
||||
await until(() => manager.get_command('pending_31')?.state === 'failed');
|
||||
for (let i = 0; i < 105; i++) await command(manager, 'trigger_stop');
|
||||
await turn();
|
||||
assert.equal(manager.get_runtime_snapshot().commands.length, 100);
|
||||
assert.equal(manager.get_command('pending_0'), undefined);
|
||||
} finally { await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('runtime updates reach context and subscribers, snapshots contain no node output and are cloned', async () => {
|
||||
const ctx = context();
|
||||
const manager = create_workflow_manager(ctx, [definition()]);
|
||||
let local = 0, context_events = 0;
|
||||
ctx.on('workflow_update', event => { if (event.type === 'runtime_updated') context_events++; });
|
||||
const off = manager.on_update(event => {
|
||||
if (event.type === 'runtime_updated') { local++; event.snapshot.workflows.length = 0; }
|
||||
});
|
||||
try {
|
||||
manager.start('one', {secret: 'hidden'}); manager.start('one');
|
||||
await command(manager, 'run_once', {input: {secret: 'hidden'}});
|
||||
await turn();
|
||||
const snapshot = manager.get_runtime_snapshot();
|
||||
assert.ok(local > 0 && context_events > 0);
|
||||
assert.equal(snapshot.workflows.length, 1);
|
||||
assert.equal(snapshot.workflows[0].active_run_ids.length, 1);
|
||||
assert.equal(snapshot.workflows[0].queued_count, 2);
|
||||
assert.equal(JSON.stringify(snapshot).includes('secret'), false);
|
||||
assert.equal(JSON.stringify(snapshot).includes('outputs'), false);
|
||||
snapshot.workflows[0].generation = 999;
|
||||
assert.equal(state(manager).generation, 1);
|
||||
const cmd = snapshot.commands[0]; cmd.state = 'failed';
|
||||
assert.equal(manager.get_command(cmd.command_id).state, 'completed');
|
||||
} finally { off(); await manager.dispose(); }
|
||||
});
|
||||
|
||||
test('trigger fields validate strictly and metadata defaults compile correctly', () => {
|
||||
for (const type of ['event', 'timer']) {
|
||||
const start = type === 'event' ? {type, event: 'hello'} : {type, interval_ms: 100};
|
||||
const graph = compile_workflow(definition('one', start));
|
||||
assert.equal(graph.definition.start.auto_start, true);
|
||||
assert.equal(graph.definition.start.mode, type === 'event' ? 'continuous' : 'repeat');
|
||||
if (type === 'timer') assert.equal(graph.definition.start.max_runs, null);
|
||||
for (const auto_start of [null, 0, 'true']) {
|
||||
assert.throws(() => compile_workflow(definition('one', {...start, auto_start})));
|
||||
assert.throws(() => validate_workflow(definition('one', {...start, auto_start})));
|
||||
}
|
||||
for (const mode of [null, true, 'invalid']) assert.throws(() => compile_workflow(definition('one', {...start, mode})));
|
||||
}
|
||||
for (const max_runs of [0, -1, 100001, 1.5, '2', false]) {
|
||||
assert.throws(() => compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs})));
|
||||
assert.throws(() => validate_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs})));
|
||||
}
|
||||
for (const max_runs of [null, 1, 100000]) compile_workflow(definition('one', {type: 'timer', interval_ms: 100, max_runs}));
|
||||
});
|
||||
Reference in New Issue
Block a user