Files
node_server/tests/workflow_hot.test.mjs
T
2026-10-22 17:07:08 +08:00

600 lines
31 KiB
JavaScript

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}));
});