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