chore: initial commit
This commit is contained in:
@@ -0,0 +1,856 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {setImmediate, setTimeout as delay} from 'node:timers/promises';
|
||||
import {tsImport} from 'tsx/esm/api';
|
||||
|
||||
const {compile_workflow} = await tsImport('../bot/workflow/runtime/compile.ts', import.meta.url);
|
||||
const {create_workflow_run} = await tsImport('../bot/workflow/runtime/machine.ts', import.meta.url);
|
||||
const {assert_json, resolve_value} = await tsImport('../bot/workflow/runtime/value.ts', import.meta.url);
|
||||
|
||||
const literal = value => ({kind: 'literal', value});
|
||||
const ref = (root, ...path) => ({kind: 'ref', root, path});
|
||||
const edge = (id, source, target, port = 'success') => ({id, source, port, target});
|
||||
const query = (id, args = {}, extra = {}) => ({id, type: 'query', config: {source: id, args, ...extra}});
|
||||
const fn = (id, args = []) => ({id, type: 'function', config: {path: 'test', name: id, args}});
|
||||
const merge = (id, mode = 'all') => ({id, type: 'merge', config: {mode}});
|
||||
const condition = (id, left, right) => ({id, type: 'condition', config: {condition: {op: 'lt', left, right}}});
|
||||
const deps = overrides => ({
|
||||
run_function: async () => true,
|
||||
query: async (_source, args) => args,
|
||||
sleep: async () => {},
|
||||
log() {},
|
||||
...overrides,
|
||||
});
|
||||
function definition(nodes, edges = [], extra = {}) {
|
||||
return {
|
||||
schema_version: 1, id: 'test_workflow', name: 'Workflow test', enabled: true,
|
||||
start: {type: 'common', auto_start: 'never'}, entry: nodes[0].id, nodes, edges,
|
||||
initial_vars: {}, policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 5000},
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
function start(t, config, dependencies = deps(), trigger = {}, notify) {
|
||||
const run = create_workflow_run(compile_workflow(config), dependencies, trigger, notify);
|
||||
t.after(() => run.stop());
|
||||
return run;
|
||||
}
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(finish => {resolve = finish;});
|
||||
return {promise, resolve};
|
||||
}
|
||||
function until_aborted(signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) reject(signal.reason);
|
||||
else signal.addEventListener('abort', () => reject(signal.reason), {once: true});
|
||||
});
|
||||
}
|
||||
function completed(snapshot, branches = 1) {
|
||||
assert.equal(snapshot.status, 'completed', JSON.stringify(snapshot.error));
|
||||
assert.equal(snapshot.error, null);
|
||||
assert.equal(snapshot.branches.length, branches);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
assert.ok(snapshot.ended_at >= snapshot.started_at);
|
||||
return snapshot.branches[0];
|
||||
}
|
||||
|
||||
test('workflow preserves the first failure while siblings drain past its deadline', {timeout: 5000}, async t => {
|
||||
const abort_seen = deferred();
|
||||
const slow_started = deferred();
|
||||
const release = deferred();
|
||||
const run = start(t, definition([query('fork'), query('bad'), query('slow')], [
|
||||
edge('fork_bad', 'fork', 'bad'), edge('fork_slow', 'fork', 'slow'),
|
||||
], {policy: {on_busy: 'skip', max_queue: 0, max_steps: 100, timeout_ms: 100}}), deps({
|
||||
query: async (source, _args, signal) => {
|
||||
if (source === 'fork') return null;
|
||||
if (source === 'bad') {
|
||||
await slow_started.promise;
|
||||
throw Object.assign(new Error('first failure'), {code: 'FIRST_FAILURE'});
|
||||
}
|
||||
slow_started.resolve();
|
||||
try {await until_aborted(signal);}
|
||||
finally {abort_seen.resolve(); await release.promise;}
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
t.after(() => release.resolve());
|
||||
await abort_seen.promise;
|
||||
await delay(125);
|
||||
release.resolve();
|
||||
const snapshot = await run.done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.stop_reason, null);
|
||||
assert.equal(snapshot.error.code, 'FIRST_FAILURE');
|
||||
});
|
||||
|
||||
test('workflow compiles JSON configs by value and resolves trigger, vars, outputs, last_result and inputs', {timeout: 5000}, async t => {
|
||||
const config = definition([
|
||||
query('read', {value: ref('trigger', 'nested', 'values', 1)}, {save_as: 'saved'}),
|
||||
fn('use', [ref('vars', 'saved'), ref('outputs', 'read', 'output'), ref('last_result', 'output'),
|
||||
ref('inputs', 'read_use', 'result', 'output'), ref('vars', 'initial')]),
|
||||
{id: 'log', type: 'log', config: {level: 'info', message: ref('last_result', 'output')}},
|
||||
], [edge('read_use', 'read', 'use'), edge('use_log', 'use', 'log')], {initial_vars: {initial: 7}});
|
||||
const serialized = JSON.parse(JSON.stringify(config));
|
||||
const graph = compile_workflow(serialized);
|
||||
serialized.nodes[0].config.source = 'changed';
|
||||
assert.equal(graph.nodes.get('read').config.source, 'read');
|
||||
const calls = [], logs = [], events = [];
|
||||
const trigger = {nested: {values: ['first', {answer: 42}]}};
|
||||
const run = create_workflow_run(graph, deps({
|
||||
query: async (_source, args, signal) => {assert.equal(signal.aborted, false); return args.value;},
|
||||
run_function: async (path, name, args, signal) => {
|
||||
assert.equal(signal.aborted, false); calls.push({path, name, args}); return true;
|
||||
},
|
||||
log: (level, message) => logs.push({level, message}),
|
||||
}), trigger, event => events.push(event));
|
||||
t.after(() => run.stop());
|
||||
trigger.nested.values[1].answer = 0;
|
||||
const snapshot = await run.done;
|
||||
const branch = completed(snapshot);
|
||||
assert.deepEqual(calls, [{path: 'test', name: 'use', args: Array(4).fill({answer: 42}).concat(7)}]);
|
||||
assert.deepEqual(logs, [{level: 'info', message: 'true'}]);
|
||||
assert.deepEqual(branch.vars, {initial: 7, saved: {answer: 42}});
|
||||
assert.deepEqual(Object.keys(branch.outputs), ['read', 'use', 'log']);
|
||||
assert.equal(snapshot.run_id, run.run_id);
|
||||
assert.equal(snapshot.steps, 3);
|
||||
assert.deepEqual(events.filter(event => event.type === 'node_finished').map(event => event.node_id), ['read', 'use', 'log']);
|
||||
snapshot.branches[0].vars.initial = 999;
|
||||
const copy = run.get_snapshot();
|
||||
assert.equal(copy.branches[0].vars.initial, 7);
|
||||
copy.branches.length = 0;
|
||||
assert.equal(run.get_snapshot().branches.length, 1);
|
||||
});
|
||||
|
||||
test('workflow forks every edge on a success port into independent variable and output state', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork'), query('left', {value: literal('L')}, {save_as: 'left_value'}),
|
||||
query('right', {value: literal('R')}, {save_as: 'right_value'}),
|
||||
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right')]),
|
||||
deps({query: async (_source, args) => args.value ?? 'root'})).done;
|
||||
completed(snapshot, 2);
|
||||
const left = snapshot.branches.find(branch => branch.outputs.left);
|
||||
const right = snapshot.branches.find(branch => branch.outputs.right);
|
||||
assert.deepEqual(left.vars, {left_value: 'L'});
|
||||
assert.deepEqual(right.vars, {right_value: 'R'});
|
||||
assert.equal(left.outputs.right, undefined);
|
||||
assert.equal(right.outputs.left, undefined);
|
||||
assert.equal(left.outputs.fork.output, 'root');
|
||||
assert.equal(right.outputs.fork.output, 'root');
|
||||
});
|
||||
|
||||
test('workflow cannot resolve a sibling output even after that sibling has finished', {timeout: 5000}, async t => {
|
||||
const sibling_finished = deferred();
|
||||
const config = definition([
|
||||
query('fork'), query('sibling'), query('wait'),
|
||||
query('illegal', {value: ref('outputs', 'sibling', 'output')}),
|
||||
], [edge('to_sibling', 'fork', 'sibling'), edge('to_wait', 'fork', 'wait'), edge('to_illegal', 'wait', 'illegal')]);
|
||||
const snapshot = await start(t, config, deps({query: async source => {
|
||||
if (source === 'wait') await sibling_finished.promise;
|
||||
return source;
|
||||
}}), {}, event => {
|
||||
if (event.type === 'node_finished' && event.node_id === 'sibling') sibling_finished.resolve();
|
||||
}).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'VALUE_NOT_FOUND');
|
||||
assert.equal(snapshot.error.node_id, 'illegal');
|
||||
});
|
||||
|
||||
test('workflow all merge exposes inputs by incoming edge id and unions branch outputs and vars', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork'), query('left', {}, {save_as: 'left_value'}), query('right', {}, {save_as: 'right_value'}),
|
||||
merge('join'), query('inspect', {left: ref('outputs', 'left', 'output'), right: ref('outputs', 'right', 'output'),
|
||||
inputs: ref('outputs', 'join', 'output')}),
|
||||
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('joined', 'join', 'inspect')]),
|
||||
deps({query: async (source, args) => source === 'inspect' ? args : source})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.deepEqual(branch.vars, {left_value: 'left', right_value: 'right'});
|
||||
assert.deepEqual(branch.outputs.join.output, {
|
||||
left_join: {source: 'left', port: 'success', result: {status: 'success', output: 'left'}},
|
||||
right_join: {source: 'right', port: 'success', result: {status: 'success', output: 'right'}},
|
||||
});
|
||||
assert.deepEqual(branch.outputs.inspect.output, {left: 'left', right: 'right', inputs: branch.outputs.join.output});
|
||||
assert.equal(snapshot.steps, 5);
|
||||
});
|
||||
|
||||
test('workflow any merge emits once per arrival without sharing sibling state', {timeout: 5000}, async t => {
|
||||
const events = [];
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork'), query('left'), query('right'), merge('join', 'any'),
|
||||
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]),
|
||||
deps({query: async source => source}), {}, event => events.push(event)).done;
|
||||
completed(snapshot, 2);
|
||||
assert.equal(events.filter(event => event.type === 'node_finished' && event.node_id === 'join').length, 2);
|
||||
assert.deepEqual(snapshot.branches.map(branch => Object.keys(branch.outputs.join.output)[0]).sort(), ['left_join', 'right_join']);
|
||||
for (const branch of snapshot.branches) assert.notEqual(Boolean(branch.outputs.left), Boolean(branch.outputs.right));
|
||||
});
|
||||
|
||||
test('workflow routes false results and thrown dependency errors through error nodes', {timeout: 5000}, async t => {
|
||||
for (const throwing of [false, true]) {
|
||||
const calls = [];
|
||||
const code = throwing ? 'TEST_FAILURE' : 'FUNCTION_RETURNED_FALSE';
|
||||
const snapshot = await start(t, definition([
|
||||
fn('fail'), {id: 'catch', type: 'error', config: {codes: [code]}},
|
||||
query('recovered', {code: ref('last_result', 'error', 'code')}), query('unexpected'),
|
||||
], [edge('failure', 'fail', 'catch', 'failure'), edge('caught', 'catch', 'recovered', 'error'),
|
||||
edge('uncaught', 'catch', 'unexpected', 'unmatched')]), deps({
|
||||
run_function: async () => {
|
||||
if (throwing) throw Object.assign(new Error('expected dependency error'), {code});
|
||||
return false;
|
||||
},
|
||||
query: async (source, args) => {calls.push(source); return args;},
|
||||
})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.deepEqual(calls, ['recovered']);
|
||||
assert.equal(branch.outputs.fail.status, 'failure');
|
||||
assert.deepEqual(branch.outputs.recovered.output, {code});
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow goto retries are bounded and exhaustion can be explicitly handled', {timeout: 5000}, async t => {
|
||||
let attempts = 0;
|
||||
const sleeps = [];
|
||||
const snapshot = await start(t, definition([
|
||||
fn('attempt'), {id: 'retry', type: 'goto', config: {max_retries: 2, delay_ms: 7}},
|
||||
query('exhausted', {code: ref('last_result', 'error', 'code')}),
|
||||
], [edge('failed', 'attempt', 'retry', 'failure'), edge('again', 'retry', 'attempt', 'retry'),
|
||||
edge('finished', 'retry', 'exhausted', 'exhausted')]), deps({
|
||||
run_function: async () => {attempts++; return false;},
|
||||
sleep: async (mode, value, signal) => {assert.equal(signal.aborted, false); sleeps.push([mode, value]);},
|
||||
})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.equal(attempts, 3);
|
||||
assert.deepEqual(sleeps, [['milliseconds', 7], ['milliseconds', 7]]);
|
||||
assert.deepEqual(branch.retry_counts, {retry: 2});
|
||||
assert.deepEqual(branch.outputs.exhausted.output, {code: 'RETRY_EXHAUSTED'});
|
||||
assert.equal(snapshot.steps, 7);
|
||||
});
|
||||
|
||||
test('workflow all merge fails with MERGE_INCOMPLETE for mutually exclusive branches', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
condition('choose', literal(1), literal(2)), query('left'), query('right'), merge('join'),
|
||||
], [edge('yes', 'choose', 'left', 'true'), edge('no', 'choose', 'right', 'false'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')])).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'MERGE_INCOMPLETE');
|
||||
assert.equal(snapshot.steps, 2);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
});
|
||||
|
||||
test('workflow conflicting branch variables fail with MERGE_CONFLICT', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork'), query('left', {value: literal(1)}, {save_as: 'shared'}),
|
||||
query('right', {value: literal(2)}, {save_as: 'shared'}), merge('join'),
|
||||
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]),
|
||||
deps({query: async (_source, args) => args.value ?? null})).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'MERGE_CONFLICT');
|
||||
assert.equal(snapshot.error.node_id, 'join');
|
||||
assert.equal(snapshot.steps, 4);
|
||||
});
|
||||
|
||||
test('workflow nested forks join inner branches before the outer all merge', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
query('root'), query('inner_fork'), query('outer_right'), query('inner_left'), query('inner_right'),
|
||||
merge('inner_join'), merge('outer_join'),
|
||||
], [edge('to_inner', 'root', 'inner_fork'), edge('to_outer_right', 'root', 'outer_right'),
|
||||
edge('to_inner_left', 'inner_fork', 'inner_left'), edge('to_inner_right', 'inner_fork', 'inner_right'),
|
||||
edge('inner_left_join', 'inner_left', 'inner_join'), edge('inner_right_join', 'inner_right', 'inner_join'),
|
||||
edge('inner_outer', 'inner_join', 'outer_join'), edge('right_outer', 'outer_right', 'outer_join')]),
|
||||
deps({query: async source => source})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.deepEqual(Object.keys(branch.outputs.inner_join.output).sort(), ['inner_left_join', 'inner_right_join']);
|
||||
assert.deepEqual(Object.keys(branch.outputs.outer_join.output).sort(), ['inner_outer', 'right_outer']);
|
||||
assert.deepEqual(Object.keys(branch.outputs).sort(), ['inner_fork', 'inner_join', 'inner_left', 'inner_right', 'outer_join', 'outer_right', 'root']);
|
||||
});
|
||||
|
||||
test('workflow repeated fork loops use a fresh all-merge group on every iteration', {timeout: 5000}, async t => {
|
||||
const pairs = [];
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork', {iteration: ref('vars', 'iteration')}),
|
||||
query('left', {iteration: ref('outputs', 'fork', 'output')}),
|
||||
query('right', {iteration: ref('outputs', 'fork', 'output')}), merge('join'),
|
||||
query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'iteration'}),
|
||||
condition('again', ref('vars', 'iteration'), literal(3)), query('done'),
|
||||
], [edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('advance', 'join', 'advance'),
|
||||
edge('check', 'advance', 'again'), edge('loop', 'again', 'fork', 'true'), edge('finish', 'again', 'done', 'false')],
|
||||
{initial_vars: {iteration: 0}}), deps({query: async (source, args) => {
|
||||
if (source === 'advance') {
|
||||
const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output];
|
||||
pairs.push(pair); return pair[0] + 1;
|
||||
}
|
||||
return args.iteration ?? null;
|
||||
}})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.deepEqual(pairs, [[0, 0], [1, 1], [2, 2]]);
|
||||
assert.equal(branch.vars.iteration, 3);
|
||||
assert.equal(snapshot.steps, 19);
|
||||
});
|
||||
|
||||
test('workflow overlapping executions of the same fork never cross-pair all-merge inputs', {timeout: 5000}, async t => {
|
||||
const arrivals = new Map();
|
||||
const all_started = deferred();
|
||||
const gates = new Map(['left:1', 'left:2', 'right:1', 'right:2'].map(key => [key, deferred()]));
|
||||
const pairs = [];
|
||||
const run = start(t, definition([
|
||||
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
|
||||
query('fork', {value: ref('last_result', 'output')}),
|
||||
query('left', {value: ref('outputs', 'fork', 'output')}),
|
||||
query('right', {value: ref('outputs', 'fork', 'output')}), merge('join'),
|
||||
query('collect', {inputs: ref('outputs', 'join', 'output')}),
|
||||
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
|
||||
edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'),
|
||||
edge('fork_left', 'fork', 'left'), edge('fork_right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('collect', 'join', 'collect')]),
|
||||
deps({query: async (source, args, signal) => {
|
||||
if (source === 'left' || source === 'right') {
|
||||
const key = `${source}:${args.value}`;
|
||||
arrivals.set(key, true);
|
||||
if (arrivals.size === 4) all_started.resolve();
|
||||
await Promise.race([gates.get(key).promise, until_aborted(signal)]);
|
||||
}
|
||||
if (source === 'collect') {
|
||||
const pair = [args.inputs.left_join.result.output, args.inputs.right_join.result.output];
|
||||
pairs.push(pair); return pair;
|
||||
}
|
||||
return args.value ?? null;
|
||||
}}));
|
||||
await all_started.promise;
|
||||
// Release opposite generations first to force a tempting but invalid join.
|
||||
for (const key of ['left:1', 'right:2', 'right:1', 'left:2']) {
|
||||
gates.get(key).resolve();
|
||||
await setImmediate();
|
||||
}
|
||||
completed(await run.done, 2);
|
||||
assert.deepEqual(pairs.sort((a, b) => a[0] - b[0]), [[1, 1], [2, 2]]);
|
||||
});
|
||||
|
||||
test('workflow retry bypassing the join owner preserves generations across repeated all merges', {timeout: 5000}, async t => {
|
||||
const gates = new Map(['a:1', 'b:2', 'b:1', 'a:2'].map(key => [key, deferred()]));
|
||||
const finished = new Map([...gates.keys()].map(key => [key, deferred()]));
|
||||
const retry_started = deferred(), first_retry_join = deferred();
|
||||
const joins = [], logs = [];
|
||||
let arrivals = 0;
|
||||
const config = definition([
|
||||
query('root'), query('seed1', {value: literal(1)}), query('seed2', {value: literal(2)}),
|
||||
query('s', {value: ref('last_result', 'output')}),
|
||||
...['a', 'b'].map(id => query(id, {value: ref('outputs', 's', 'output'), outputs: ref('outputs')})),
|
||||
merge('join'), {id: 'retry', type: 'goto', config: {max_retries: 1, delay_ms: 0}},
|
||||
{id: 'end', type: 'log', config: {level: 'info', message: ref('outputs', 's', 'output')}},
|
||||
], [edge('seed1', 'root', 'seed1'), edge('seed2', 'root', 'seed2'),
|
||||
edge('seed1_s', 'seed1', 's'), edge('seed2_s', 'seed2', 's'),
|
||||
edge('s_a', 's', 'a'), edge('s_b', 's', 'b'), edge('a_join', 'a', 'join'), edge('b_join', 'b', 'join'),
|
||||
edge('join_retry', 'join', 'retry'), edge('retry_a', 'retry', 'a', 'retry'),
|
||||
edge('retry_b', 'retry', 'b', 'retry'), edge('exhausted', 'retry', 'end', 'exhausted')]);
|
||||
assert.equal(compile_workflow(config).join_owners.get('join'), 's');
|
||||
const run = start(t, config, deps({query: async (source, args, signal) => {
|
||||
if (source !== 'a' && source !== 'b') return args.value ?? null;
|
||||
const round = Object.hasOwn(args.outputs, 'retry') ? 2 : 1;
|
||||
if (round === 2) {
|
||||
if (++arrivals === 4) retry_started.resolve();
|
||||
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
|
||||
}
|
||||
return {generation: args.value, round};
|
||||
}, log: (level, message) => logs.push({level, message})}), {}, event => {
|
||||
if (event.type !== 'node_finished') return;
|
||||
if (event.node_id === 'join') {
|
||||
const inputs = event.result.output;
|
||||
joins.push([inputs.a_join.result.output, inputs.b_join.result.output]);
|
||||
if (joins.length === 3) first_retry_join.resolve();
|
||||
} else if (event.node_id === 'a' || event.node_id === 'b') {
|
||||
if (event.result.output.round === 2) {
|
||||
finished.get(`${event.node_id}:${event.result.output.generation}`).resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
await retry_started.promise;
|
||||
const expected = (generation, round) => Array.from({length: 2}, () => ({generation, round}));
|
||||
assert.deepEqual(joins.slice().sort((a, b) => a[0].generation - b[0].generation), [expected(1, 1), expected(2, 1)]);
|
||||
// These arrivals would cross-pair if the first join discarded its lineage.
|
||||
for (const key of ['a:1', 'b:2']) {
|
||||
gates.get(key).resolve(); await finished.get(key).promise;
|
||||
}
|
||||
await setImmediate();
|
||||
assert.equal(joins.length, 2);
|
||||
gates.get('b:1').resolve();
|
||||
await first_retry_join.promise;
|
||||
assert.deepEqual(joins[2], expected(1, 2));
|
||||
gates.get('a:2').resolve();
|
||||
const snapshot = await run.done;
|
||||
completed(snapshot, 2);
|
||||
assert.deepEqual(joins[3], expected(2, 2));
|
||||
assert.equal(snapshot.steps, 23);
|
||||
assert.deepEqual(logs.sort((a, b) => a.message.localeCompare(b.message)),
|
||||
[{level: 'info', message: '1'}, {level: 'info', message: '2'}]);
|
||||
for (const branch of snapshot.branches) {
|
||||
assert.deepEqual(branch.retry_counts, {retry: 1});
|
||||
assert.equal(branch.outputs.retry.error.code, 'RETRY_EXHAUSTED');
|
||||
assert.equal(branch.outputs.join.output.a_join.result.output.generation, branch.outputs.s.output);
|
||||
assert.equal(branch.outputs.join.output.b_join.result.output.generation, branch.outputs.s.output);
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow node timeout aborts and drains its dependency before routing failure', {timeout: 5000}, async t => {
|
||||
let aborted = false, drained = false;
|
||||
const snapshot = await start(t, definition([
|
||||
{...query('slow'), timeout_ms: 100}, query('recover', {code: ref('last_result', 'error', 'code')}),
|
||||
], [edge('timeout', 'slow', 'recover', 'failure')]), deps({query: async (source, args, signal) => {
|
||||
if (source !== 'slow') {assert.equal(drained, true); return args;}
|
||||
try {await until_aborted(signal);} finally {aborted = signal.aborted; await setImmediate(); drained = true;}
|
||||
}})).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.equal(aborted, true);
|
||||
assert.equal(drained, true);
|
||||
assert.equal(branch.outputs.slow.error.code, 'NODE_TIMEOUT');
|
||||
assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'});
|
||||
});
|
||||
|
||||
test('workflow partial nested forks retain outer join generations across overlapping executions', {timeout: 5000}, async t => {
|
||||
const leaves = ['inner_left', 'inner_right', 'outer_right'];
|
||||
const keys = leaves.flatMap(id => [1, 2].map(value => `${id}:${value}`));
|
||||
const gates = new Map(keys.map(key => [key, deferred()]));
|
||||
const finished = new Map(keys.map(key => [key, deferred()]));
|
||||
const all_started = deferred(), first_join = deferred();
|
||||
const triples = [];
|
||||
let arrivals = 0;
|
||||
const run = start(t, definition([
|
||||
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
|
||||
query('outer_fork', {value: ref('last_result', 'output')}),
|
||||
query('partial', {value: ref('last_result', 'output')}),
|
||||
...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'),
|
||||
query('collect', {inputs: ref('outputs', 'join', 'output')}),
|
||||
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
|
||||
edge('a_outer', 'seed_a', 'outer_fork'), edge('b_outer', 'seed_b', 'outer_fork'),
|
||||
edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'),
|
||||
edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'),
|
||||
...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('collect', 'join', 'collect')]),
|
||||
deps({query: async (source, args, signal) => {
|
||||
if (leaves.includes(source)) {
|
||||
if (++arrivals === 6) all_started.resolve();
|
||||
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
|
||||
}
|
||||
if (source === 'collect') {
|
||||
const triple = leaves.map(id => args.inputs[`${id}_join`].result.output);
|
||||
triples.push(triple); first_join.resolve(); return triple;
|
||||
}
|
||||
return args.value ?? null;
|
||||
}}), {}, event => {
|
||||
if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve();
|
||||
});
|
||||
await all_started.promise;
|
||||
for (const key of ['inner_left:1', 'inner_right:2', 'outer_right:1']) {
|
||||
gates.get(key).resolve(); await finished.get(key).promise;
|
||||
}
|
||||
await setImmediate();
|
||||
assert.deepEqual(triples, []);
|
||||
gates.get('inner_right:1').resolve();
|
||||
await first_join.promise;
|
||||
assert.deepEqual(triples, [[1, 1, 1]]);
|
||||
gates.get('inner_left:2').resolve(); gates.get('outer_right:2').resolve();
|
||||
completed(await run.done, 2);
|
||||
assert.deepEqual(triples, [[1, 1, 1], [2, 2, 2]]);
|
||||
});
|
||||
|
||||
test('workflow partial nested forks open fresh outer join generations over repeated rounds', {timeout: 5000}, async t => {
|
||||
const leaves = ['inner_left', 'inner_right', 'outer_right'];
|
||||
const triples = [];
|
||||
const snapshot = await start(t, definition([
|
||||
query('outer_fork', {value: ref('vars', 'round')}), query('partial', {value: ref('last_result', 'output')}),
|
||||
...leaves.map(id => query(id, {value: ref('last_result', 'output')})), merge('join'),
|
||||
query('advance', {inputs: ref('outputs', 'join', 'output')}, {save_as: 'round'}),
|
||||
condition('again', ref('vars', 'round'), literal(3)), query('done'),
|
||||
], [edge('partial', 'outer_fork', 'partial'), edge('outer_right', 'outer_fork', 'outer_right'),
|
||||
edge('inner_left', 'partial', 'inner_left'), edge('inner_right', 'partial', 'inner_right'),
|
||||
...leaves.map(id => edge(`${id}_join`, id, 'join')), edge('advance', 'join', 'advance'),
|
||||
edge('check', 'advance', 'again'), edge('loop', 'again', 'outer_fork', 'true'), edge('done', 'again', 'done', 'false')],
|
||||
{initial_vars: {round: 0}}), deps({query: async (source, args) => {
|
||||
if (source === 'advance') {
|
||||
const triple = leaves.map(id => args.inputs[`${id}_join`].result.output);
|
||||
triples.push(triple); return triple[0] + 1;
|
||||
}
|
||||
return args.value ?? null;
|
||||
}})).done;
|
||||
assert.equal(completed(snapshot).vars.round, 3);
|
||||
assert.deepEqual(triples, [[0, 0, 0], [1, 1, 1], [2, 2, 2]]);
|
||||
assert.equal(snapshot.steps, 25);
|
||||
});
|
||||
|
||||
test('workflow successive nested all joins preserve overlapping fork generations', {timeout: 5000}, async t => {
|
||||
const gates = new Map(['x:1', 'x:2', 'c:1', 'c:2'].map(key => [key, deferred()]));
|
||||
const finished = new Map([...gates.keys()].map(key => [key, deferred()]));
|
||||
const all_started = deferred(), first_join = deferred();
|
||||
let arrivals = 0;
|
||||
const pairs = [];
|
||||
const run = start(t, definition([
|
||||
query('root'), query('seed_a', {value: literal(1)}), query('seed_b', {value: literal(2)}),
|
||||
query('fork', {value: ref('last_result', 'output')}),
|
||||
...['a', 'b', 'c'].map(id => query(id, {value: ref('outputs', 'fork', 'output')})), merge('inner'),
|
||||
query('x', {value: ref('outputs', 'fork', 'output')}), merge('outer'),
|
||||
query('collect', {inputs: ref('outputs', 'outer', 'output')}),
|
||||
], [edge('seed_a', 'root', 'seed_a'), edge('seed_b', 'root', 'seed_b'),
|
||||
edge('a_fork', 'seed_a', 'fork'), edge('b_fork', 'seed_b', 'fork'),
|
||||
...['a', 'b', 'c'].map(id => edge(`fork_${id}`, 'fork', id)),
|
||||
edge('a_inner', 'a', 'inner'), edge('b_inner', 'b', 'inner'), edge('inner_x', 'inner', 'x'),
|
||||
edge('x_outer', 'x', 'outer'), edge('c_outer', 'c', 'outer'), edge('collect', 'outer', 'collect')]),
|
||||
deps({query: async (source, args, signal) => {
|
||||
if (source === 'x' || source === 'c') {
|
||||
if (++arrivals === 4) all_started.resolve();
|
||||
await Promise.race([gates.get(`${source}:${args.value}`).promise, until_aborted(signal)]);
|
||||
}
|
||||
if (source === 'collect') {
|
||||
const pair = [args.inputs.x_outer.result.output, args.inputs.c_outer.result.output];
|
||||
pairs.push(pair); first_join.resolve(); return pair;
|
||||
}
|
||||
return args.value ?? null;
|
||||
}}), {}, event => {
|
||||
if (event.type === 'node_finished') finished.get(`${event.node_id}:${event.result.output}`)?.resolve();
|
||||
});
|
||||
await all_started.promise;
|
||||
for (const key of ['x:1', 'c:2']) {
|
||||
gates.get(key).resolve(); await finished.get(key).promise;
|
||||
}
|
||||
await setImmediate();
|
||||
assert.deepEqual(pairs, []);
|
||||
gates.get('c:1').resolve();
|
||||
await first_join.promise;
|
||||
assert.deepEqual(pairs, [[1, 1]]);
|
||||
gates.get('x:2').resolve();
|
||||
completed(await run.done, 2);
|
||||
assert.deepEqual(pairs, [[1, 1], [2, 2]]);
|
||||
});
|
||||
|
||||
test('workflow all merge accepts equal branch vars with different object key insertion order', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'),
|
||||
], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join')]), deps({query: async source => {
|
||||
if (source === 'left') return {a: 1, nested: {x: 2, y: [3]}};
|
||||
if (source === 'right') return {nested: {y: [3], x: 2}, a: 1};
|
||||
return null;
|
||||
}})).done;
|
||||
assert.deepEqual(completed(snapshot).vars.shared, {a: 1, nested: {x: 2, y: [3]}});
|
||||
});
|
||||
|
||||
test('workflow all merge keeps one branch write when its sibling retains the inherited value', {timeout: 5000}, async t => {
|
||||
const branch = completed(await start(t, definition([
|
||||
query('fork'), query('write', {}, {save_as: 'shared'}), query('unchanged'), merge('join'),
|
||||
], [edge('write', 'fork', 'write'), edge('unchanged', 'fork', 'unchanged'),
|
||||
edge('write_join', 'write', 'join'), edge('unchanged_join', 'unchanged', 'join')],
|
||||
{initial_vars: {shared: {value: 0}, preserved: true}}),
|
||||
deps({query: async source => source === 'write' ? {value: 1} : null})).done);
|
||||
assert.deepEqual(branch.vars, {shared: {value: 1}, preserved: true});
|
||||
});
|
||||
|
||||
test('workflow merge conflicts route through merge failure edges with the merge node identity', {timeout: 5000}, async t => {
|
||||
const logs = [];
|
||||
const branch = completed(await start(t, definition([
|
||||
query('fork'), query('left', {}, {save_as: 'shared'}), query('right', {}, {save_as: 'shared'}), merge('join'),
|
||||
{id: 'recover', type: 'log', config: {level: 'warning', message: ref('last_result', 'error', 'code')}},
|
||||
], [edge('left', 'fork', 'left'), edge('right', 'fork', 'right'),
|
||||
edge('left_join', 'left', 'join'), edge('right_join', 'right', 'join'), edge('recover', 'join', 'recover', 'failure')]),
|
||||
deps({query: async source => source,
|
||||
log: (level, message) => logs.push({level, message})})).done);
|
||||
assert.equal(branch.outputs.join.status, 'failure');
|
||||
assert.equal(branch.outputs.join.error.code, 'MERGE_CONFLICT');
|
||||
assert.equal(branch.outputs.join.error.node_id, 'join');
|
||||
assert.deepEqual(logs, [{level: 'warning', message: 'MERGE_CONFLICT'}]);
|
||||
});
|
||||
|
||||
test('workflow schedules at most 16 dependencies and resumes queued work as slots free', {timeout: 5000}, async t => {
|
||||
const gates = Array.from({length: 32}, deferred);
|
||||
const started = Array.from({length: 32}, deferred);
|
||||
let active = 0, peak = 0, calls = 0;
|
||||
const run = start(t, definition([query('fork'), query('work', {inputs: ref('inputs')})],
|
||||
gates.map((_, index) => edge(`work_${index}`, 'fork', 'work'))), deps({query: async (source, args, signal) => {
|
||||
if (source === 'fork') return null;
|
||||
const index = Number(Object.keys(args.inputs)[0].slice('work_'.length));
|
||||
calls++; active++; peak = Math.max(peak, active); started[index].resolve();
|
||||
try {await Promise.race([gates[index].promise, until_aborted(signal)]); return index;}
|
||||
finally {active--;}
|
||||
}}));
|
||||
await Promise.all(started.slice(0, 16).map(item => item.promise));
|
||||
await setImmediate();
|
||||
assert.equal(calls, 16);
|
||||
assert.equal(active, 16);
|
||||
assert.equal(run.get_snapshot().active_nodes.length, 16);
|
||||
gates[0].resolve();
|
||||
await started[16].promise;
|
||||
assert.equal(calls, 17);
|
||||
assert.equal(active, 16);
|
||||
gates.forEach(item => item.resolve());
|
||||
const snapshot = await run.done;
|
||||
completed(snapshot, 32);
|
||||
assert.equal(peak, 16);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(calls, 32);
|
||||
assert.equal(snapshot.steps, 33);
|
||||
});
|
||||
|
||||
test('workflow token bound accepts 256 branches and rejects 257 before dispatching children', {timeout: 5000}, async t => {
|
||||
for (const count of [256, 257]) {
|
||||
let children = 0;
|
||||
const config = definition([query('fork'), query('leaf')],
|
||||
Array.from({length: count}, (_, index) => edge(`leaf_${index}`, 'fork', 'leaf')));
|
||||
config.policy.max_steps = 512;
|
||||
const snapshot = await start(t, config, deps({query: async source => {
|
||||
if (source === 'leaf') children++;
|
||||
return null;
|
||||
}})).done;
|
||||
if (count === 256) {
|
||||
completed(snapshot, 256);
|
||||
assert.equal(children, 256);
|
||||
assert.equal(snapshot.steps, 257);
|
||||
} else {
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED');
|
||||
assert.equal(children, 0);
|
||||
assert.equal(snapshot.steps, 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow token bound includes completed branches and tokens waiting at all merges', {timeout: 5000}, async t => {
|
||||
for (const waiting of [false, true]) {
|
||||
const nodes = [query('root'), query('leaf'), query('expand'), ...(waiting ? [merge('join')] : [query('sink')])];
|
||||
const target = waiting ? 'join' : 'sink';
|
||||
const edges = [
|
||||
...Array.from({length: 255}, (_, index) => edge(`leaf_${index}`, 'root', 'leaf')),
|
||||
edge('expand', 'root', 'expand'), edge('expanded_a', 'expand', target), edge('expanded_b', 'expand', target),
|
||||
...(waiting ? [edge('leaf_join', 'leaf', 'join')] : []),
|
||||
];
|
||||
const config = definition(nodes, edges);
|
||||
config.policy.max_steps = 1024;
|
||||
const snapshot = await start(t, config).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'BRANCH_LIMIT_EXCEEDED');
|
||||
assert.equal(snapshot.error.node_id, 'expand');
|
||||
assert.equal(snapshot.steps, 257);
|
||||
assert.equal(snapshot.branches.length, waiting ? 0 : 255);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow completion scheduling advances a loop beside a pending sibling and drains at the step limit', {timeout: 5000}, async t => {
|
||||
const pending_started = deferred();
|
||||
let iterations = 0, drained = false;
|
||||
const config = definition([query('fork'), query('pending'), query('loop')],
|
||||
[edge('pending', 'fork', 'pending'), edge('loop', 'fork', 'loop'), edge('again', 'loop', 'loop')]);
|
||||
config.policy.max_steps = 128;
|
||||
const snapshot = await start(t, config, deps({query: async (source, _args, signal) => {
|
||||
if (source === 'fork') return null;
|
||||
if (source === 'pending') {
|
||||
pending_started.resolve();
|
||||
try {await until_aborted(signal);} finally {await setImmediate(); drained = true;}
|
||||
}
|
||||
await pending_started.promise;
|
||||
return ++iterations;
|
||||
}})).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED');
|
||||
assert.equal(snapshot.steps, 128);
|
||||
assert.equal(iterations, 126);
|
||||
assert.equal(drained, true);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
});
|
||||
|
||||
test('workflow JSON guards reject prototypes, accessors and cycles without invoking getters', () => {
|
||||
let reads = 0;
|
||||
const accessor = Object.defineProperty({}, 'secret', {enumerable: true, get() {reads++; return 1;}});
|
||||
const cyclic = {}; cyclic.self = cyclic;
|
||||
for (const value of [Object.create({inherited: 1}), JSON.parse('{"__proto__":1}'), accessor, cyclic]) {
|
||||
assert.throws(() => assert_json(value), {code: 'INVALID_JSON'});
|
||||
}
|
||||
const state = {trigger: accessor, vars: Object.create({inherited: 1}), outputs: {}, last_result: null, inputs: {}, retry_counts: {}};
|
||||
assert.throws(() => resolve_value(ref('trigger', 'secret'), state), {code: 'VALUE_NOT_FOUND'});
|
||||
assert.throws(() => resolve_value(ref('vars', 'inherited'), state), {code: 'VALUE_NOT_FOUND'});
|
||||
assert.throws(() => resolve_value(ref('vars', '__proto__'), state), {code: 'INVALID_VALUE_SOURCE'});
|
||||
assert.throws(() => resolve_value(literal(cyclic), state), {code: 'INVALID_JSON'});
|
||||
assert.equal(reads, 0);
|
||||
const shared = {value: [null, true, 2, 'json']};
|
||||
assert.doesNotThrow(() => assert_json({left: shared, right: shared}));
|
||||
assert.deepEqual(resolve_value(ref('trigger', 'value', 2), {...state, trigger: shared}), 2);
|
||||
});
|
||||
|
||||
test('workflow code workers normalize oversized and non-JSON results into routable failures', {timeout: 5000}, async t => {
|
||||
for (const source of ['return "x".repeat(1048577);', 'return undefined;', 'return NaN;',
|
||||
'const value = {}; value.self = value; return value;']) {
|
||||
const snapshot = await start(t, definition([
|
||||
{id: 'code', type: 'code', config: {source}}, query('recover', {code: ref('last_result', 'error', 'code')}),
|
||||
], [edge('failure', 'code', 'recover', 'failure')])).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.equal(branch.outputs.code.status, 'failure');
|
||||
assert.equal(branch.outputs.code.output, null);
|
||||
assert.equal(branch.outputs.code.error.code, 'INVALID_JSON');
|
||||
assert.equal(branch.outputs.code.error.node_id, 'code');
|
||||
assert.deepEqual(branch.outputs.recover.output, {code: 'INVALID_JSON'});
|
||||
}
|
||||
});
|
||||
|
||||
test('workflow code worker infinite loop times out and routes NODE_TIMEOUT after termination', {timeout: 5000}, async t => {
|
||||
const snapshot = await start(t, definition([
|
||||
{id: 'code', type: 'code', timeout_ms: 150, config: {source: 'while (true) {}'}},
|
||||
query('recover', {code: ref('last_result', 'error', 'code')}),
|
||||
], [edge('timeout', 'code', 'recover', 'failure')])).done;
|
||||
const branch = completed(snapshot);
|
||||
assert.equal(snapshot.stop_reason, null);
|
||||
assert.equal(branch.outputs.code.error.code, 'NODE_TIMEOUT');
|
||||
assert.deepEqual(branch.outputs.recover.output, {code: 'NODE_TIMEOUT'});
|
||||
});
|
||||
|
||||
test('workflow run timeout aborts dependencies and finishes as timed_out', {timeout: 5000}, async t => {
|
||||
let drained = false;
|
||||
const config = definition([query('slow')]);
|
||||
config.policy.timeout_ms = 150;
|
||||
const snapshot = await start(t, config, deps({query: async (_source, _args, signal) => {
|
||||
try {await until_aborted(signal);} finally {await setImmediate(); drained = true;}
|
||||
}})).done;
|
||||
assert.equal(snapshot.status, 'timed_out');
|
||||
assert.equal(snapshot.stop_reason, 'timeout');
|
||||
assert.equal(drained, true);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
assert.equal(snapshot.branches.length, 0);
|
||||
});
|
||||
|
||||
test('workflow cancellation drains all cooperative fork dependencies before stop or done resolves', {timeout: 5000}, async t => {
|
||||
const started = Array.from({length: 3}, deferred);
|
||||
const aborted = Array.from({length: 3}, deferred);
|
||||
const cleanup = deferred();
|
||||
let active = 0, drained = 0;
|
||||
const block = async (index, signal) => {
|
||||
active++; started[index].resolve();
|
||||
try {await until_aborted(signal);} finally {
|
||||
aborted[index].resolve(); await cleanup.promise; active--; drained++;
|
||||
}
|
||||
};
|
||||
t.after(() => cleanup.resolve());
|
||||
const run = start(t, definition([
|
||||
query('fork'), fn('function_wait'), query('query_wait'),
|
||||
{id: 'sleep_wait', type: 'sleep', config: {mode: 'ticks', value: 10}},
|
||||
], [edge('function', 'fork', 'function_wait'), edge('query', 'fork', 'query_wait'), edge('sleep', 'fork', 'sleep_wait')]),
|
||||
deps({run_function: (_path, _name, _args, signal) => block(0, signal),
|
||||
query: (source, _args, signal) => source === 'fork' ? Promise.resolve(null) : block(1, signal),
|
||||
sleep: (_mode, _value, signal) => block(2, signal)}));
|
||||
await Promise.all(started.map(item => item.promise));
|
||||
let stop_finished = false, done_finished = false;
|
||||
const stopping = run.stop('disconnect').then(() => {stop_finished = true;});
|
||||
run.done.then(() => {done_finished = true;});
|
||||
await Promise.all(aborted.map(item => item.promise));
|
||||
await setImmediate();
|
||||
assert.equal(run.get_snapshot().status, 'stopping');
|
||||
assert.equal(active, 3);
|
||||
assert.equal(stop_finished, false);
|
||||
assert.equal(done_finished, false);
|
||||
cleanup.resolve();
|
||||
await stopping;
|
||||
const snapshot = await run.done;
|
||||
assert.equal(snapshot.status, 'cancelled');
|
||||
assert.equal(snapshot.stop_reason, 'disconnect');
|
||||
assert.equal(active, 0);
|
||||
assert.equal(drained, 3);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
assert.equal(snapshot.branches.length, 0);
|
||||
await run.stop('manual');
|
||||
assert.equal(run.get_snapshot().stop_reason, 'disconnect');
|
||||
});
|
||||
|
||||
test('workflow unhandled failure aborts and drains sibling work before done resolves', {timeout: 5000}, async t => {
|
||||
const started = deferred(), aborted = deferred(), cleanup = deferred();
|
||||
let drained = false, settled = false;
|
||||
t.after(() => cleanup.resolve());
|
||||
const run = start(t, definition([query('fork'), fn('fail'), query('wait')],
|
||||
[edge('fail', 'fork', 'fail'), edge('wait', 'fork', 'wait')]), deps({
|
||||
run_function: async () => {await started.promise; return false;},
|
||||
query: async (source, _args, signal) => {
|
||||
if (source === 'fork') return null;
|
||||
started.resolve();
|
||||
try {await until_aborted(signal);} finally {
|
||||
aborted.resolve(); await cleanup.promise; drained = true;
|
||||
}
|
||||
},
|
||||
}));
|
||||
run.done.then(() => {settled = true;});
|
||||
await aborted.promise;
|
||||
await setImmediate();
|
||||
assert.equal(settled, false);
|
||||
assert.equal(drained, false);
|
||||
cleanup.resolve();
|
||||
const snapshot = await run.done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'FUNCTION_RETURNED_FALSE');
|
||||
assert.equal(drained, true);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
assert.equal(snapshot.branches.length, 1);
|
||||
assert.equal(snapshot.branches[0].outputs.wait, undefined);
|
||||
});
|
||||
|
||||
test('workflow max_steps terminates cycles at the configured execution budget', {timeout: 5000}, async t => {
|
||||
const config = definition([query('loop')], [edge('again', 'loop', 'loop')]);
|
||||
config.policy.max_steps = 5;
|
||||
let calls = 0;
|
||||
const snapshot = await start(t, config, deps({query: async () => ++calls})).done;
|
||||
assert.equal(snapshot.status, 'failed');
|
||||
assert.equal(snapshot.error.code, 'STEP_LIMIT_EXCEEDED');
|
||||
assert.equal(snapshot.steps, 5);
|
||||
assert.equal(calls, 5);
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
});
|
||||
|
||||
test('workflow code worker returns JSON from trigger state', {timeout: 5000}, async t => {
|
||||
const branch = completed(await start(t, definition([
|
||||
{id: 'code', type: 'code', config: {source: 'return {value: state.trigger.value, initial: state.vars.initial};'}},
|
||||
], [], {initial_vars: {initial: 8}}), deps(), {value: ['worker', 42]}).done);
|
||||
assert.deepEqual(branch.outputs.code.output, {value: ['worker', 42], initial: 8});
|
||||
});
|
||||
|
||||
test('workflow cancelling an infinite code loop terminates and drains its worker', {timeout: 5000}, async t => {
|
||||
const entered = deferred();
|
||||
const run = start(t, definition([
|
||||
{id: 'loop', type: 'code', config: {source: 'while (true) {}'}},
|
||||
]), deps(), {}, event => {
|
||||
if (event.type === 'node_started' && event.node_id === 'loop') entered.resolve();
|
||||
});
|
||||
await entered.promise;
|
||||
await delay(75);
|
||||
assert.equal(run.get_snapshot().status, 'running');
|
||||
await run.stop('manual');
|
||||
const snapshot = await run.done;
|
||||
assert.equal(snapshot.status, 'cancelled');
|
||||
assert.deepEqual(snapshot.active_nodes, []);
|
||||
assert.equal(snapshot.branches.length, 0);
|
||||
});
|
||||
|
||||
test('workflow schema rejects malformed definitions, references, policies and graph topology', async t => {
|
||||
const valid = () => definition([query('first'), query('last')], [edge('next', 'first', 'last')]);
|
||||
const cases = [
|
||||
['schema version', value => {value.schema_version = 2;}],
|
||||
['duplicate node', value => {value.nodes.push(structuredClone(value.nodes[0]));}],
|
||||
['duplicate edge', value => {value.edges.push(structuredClone(value.edges[0]));}],
|
||||
['dangling source', value => {value.edges[0].source = 'missing';}],
|
||||
['dangling target', value => {value.edges[0].target = 'missing';}],
|
||||
['missing entry', value => {value.entry = 'missing';}],
|
||||
['unreachable node', value => {value.nodes.push(query('unreachable'));}],
|
||||
['invalid port', value => {value.edges[0].port = 'true';}],
|
||||
['unknown node type', value => {value.nodes[0].type = 'unknown';}],
|
||||
['zero step budget', value => {value.policy.max_steps = 0;}],
|
||||
['fractional step budget', value => {value.policy.max_steps = 1.5;}],
|
||||
['negative queue', value => {value.policy.max_queue = -1;}],
|
||||
['invalid busy policy', value => {value.policy.on_busy = 'parallel';}],
|
||||
['zero run timeout', value => {value.policy.timeout_ms = 0;}],
|
||||
['zero node timeout', value => {value.nodes[0].timeout_ms = 0;}],
|
||||
['invalid reference root', value => {value.nodes[0].config.args.x = ref('unknown');}],
|
||||
['unsafe reference path', value => {value.nodes[0].config.args.x = ref('vars', '__proto__');}],
|
||||
['negative reference index', value => {value.nodes[0].config.args.x = ref('trigger', -1);}],
|
||||
['non-JSON literal', value => {value.nodes[0].config.args.x = literal(NaN);}],
|
||||
['goto without retry target', value => {value.nodes[1] = {id: 'last', type: 'goto', config: {max_retries: 2, delay_ms: 0}};}],
|
||||
['all merge with one input', value => {value.nodes[1] = merge('last');}],
|
||||
['unsafe initial var', value => {value.initial_vars = JSON.parse('{"__proto__":1}');}],
|
||||
];
|
||||
for (const [name, mutate] of cases) await t.test(name, () => {
|
||||
const value = valid(); mutate(value);
|
||||
assert.throws(() => compile_workflow(value));
|
||||
});
|
||||
assert.doesNotThrow(() => compile_workflow(valid()));
|
||||
});
|
||||
Reference in New Issue
Block a user