mirror of
https://github.com/openclaw/lobster.git
synced 2026-08-14 00:48:09 +00:00
feat: add comparison operators for workflow conditions
This commit is contained in:
@@ -7,6 +7,7 @@ All notable changes to Lobster will be documented in this file.
|
||||
- Add workflow composition via `workflow:` + `workflow_args`, including recursive sub-workflow execution, cycle detection, and dry-run visibility for workflow steps. Sub-workflow approval/input halts are rejected with resume-state cleanup. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#73](https://github.com/openclaw/lobster/pull/73)).
|
||||
- Add per-step `on_error` workflow policies (`stop|continue|skip_rest`) for partial-failure recovery, with structured step error fields (`error`, `errorMessage`) for condition-based branching. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#72](https://github.com/openclaw/lobster/pull/72)).
|
||||
- Add per-step workflow `timeout_ms` handling, including timeout-triggered aborts, `SIGKILL` for timed shell steps, and dry-run annotations. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#74](https://github.com/openclaw/lobster/pull/74)).
|
||||
- Add workflow condition comparison operators `<`, `<=`, `>`, and `>=` with strict numeric semantics (booleans/null do not coerce), including mixed boolean-expression support with `&&`/`||`. Thanks to [@scottgl9](https://github.com/scottgl9) (PR [#71](https://github.com/openclaw/lobster/pull/71)).
|
||||
|
||||
## 2026.4.6
|
||||
|
||||
|
||||
+44
-1
@@ -1270,7 +1270,7 @@ function getValueByPath(value: unknown, pathValue: string) {
|
||||
}
|
||||
|
||||
type ConditionToken =
|
||||
| { type: 'lparen' | 'rparen' | 'and' | 'or' | 'eq' | 'neq' | 'not' }
|
||||
| { type: 'lparen' | 'rparen' | 'and' | 'or' | 'eq' | 'neq' | 'lt' | 'lte' | 'gt' | 'gte' | 'not' }
|
||||
| { type: 'step_ref'; value: { id: string; path: string } }
|
||||
| { type: 'string' | 'number' | 'boolean' | 'null' | 'identifier'; value: unknown };
|
||||
|
||||
@@ -1310,6 +1310,18 @@ function evaluateConditionExpression(
|
||||
if (match('neq')) {
|
||||
return !compareConditionValues(left, parseUnary(true));
|
||||
}
|
||||
if (match('lt')) {
|
||||
return numericCompare(left, parseUnary(true), (a, b) => a < b);
|
||||
}
|
||||
if (match('lte')) {
|
||||
return numericCompare(left, parseUnary(true), (a, b) => a <= b);
|
||||
}
|
||||
if (match('gt')) {
|
||||
return numericCompare(left, parseUnary(true), (a, b) => a > b);
|
||||
}
|
||||
if (match('gte')) {
|
||||
return numericCompare(left, parseUnary(true), (a, b) => a >= b);
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
@@ -1370,6 +1382,17 @@ function compareConditionValues(left: unknown, right: unknown) {
|
||||
return Object.is(left, right);
|
||||
}
|
||||
|
||||
function isStrictlyNumeric(value: unknown): boolean {
|
||||
if (typeof value === 'number') return !Number.isNaN(value);
|
||||
if (typeof value === 'string') return value.trim() !== '' && !Number.isNaN(Number(value));
|
||||
return false;
|
||||
}
|
||||
|
||||
function numericCompare(left: unknown, right: unknown, cmp: (a: number, b: number) => boolean): boolean {
|
||||
if (!isStrictlyNumeric(left) || !isStrictlyNumeric(right)) return false;
|
||||
return cmp(Number(left), Number(right));
|
||||
}
|
||||
|
||||
function isPlainConditionObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -1414,6 +1437,26 @@ function tokenizeCondition(expression: string): ConditionToken[] {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (expression.startsWith('<=', index)) {
|
||||
tokens.push({ type: 'lte' });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (expression.startsWith('>=', index)) {
|
||||
tokens.push({ type: 'gte' });
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === '<') {
|
||||
tokens.push({ type: 'lt' });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '>') {
|
||||
tokens.push({ type: 'gt' });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '!') {
|
||||
tokens.push({ type: 'not' });
|
||||
index += 1;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { runWorkflowFile } from '../src/workflows/file.js';
|
||||
|
||||
async function runWorkflow(workflow: unknown) {
|
||||
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'lobster-cond-'));
|
||||
const stateDir = path.join(tmpDir, 'state');
|
||||
const filePath = path.join(tmpDir, 'workflow.lobster');
|
||||
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), 'utf8');
|
||||
|
||||
return runWorkflowFile({
|
||||
filePath,
|
||||
ctx: {
|
||||
stdin: process.stdin,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
|
||||
mode: 'tool',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('condition > works with numbers', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({count:5}))"' },
|
||||
{ id: 'check', command: 'echo "big"', when: '$data.json.count > 3' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['big\n']);
|
||||
});
|
||||
|
||||
test('condition > skips when false', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({count:1}))"' },
|
||||
{ id: 'check', command: 'echo "big"', when: '$data.json.count > 3' },
|
||||
{ id: 'fallback', command: 'echo "small"' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['small\n']);
|
||||
});
|
||||
|
||||
test('condition < works', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:2}))"' },
|
||||
{ id: 'check', command: 'echo "low"', when: '$data.json.val < 10' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['low\n']);
|
||||
});
|
||||
|
||||
test('condition >= works at boundary', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
|
||||
{ id: 'check', command: 'echo "yes"', when: '$data.json.val >= 5' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['yes\n']);
|
||||
});
|
||||
|
||||
test('condition <= works at boundary', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:5}))"' },
|
||||
{ id: 'check', command: 'echo "yes"', when: '$data.json.val <= 5' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['yes\n']);
|
||||
});
|
||||
|
||||
test('comparison operators combine with boolean operators', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({a:5,b:20}))"' },
|
||||
{ id: 'check', command: 'echo "in range"', when: '$data.json.a >= 1 && $data.json.b < 100' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['in range\n']);
|
||||
});
|
||||
|
||||
test('comparison with non-numeric string returns false', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:\\"hello\\"}))"' },
|
||||
{ id: 'check', command: 'echo "yes"', when: '$data.json.val > 3' },
|
||||
{ id: 'fallback', command: 'echo "no"' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['no\n']);
|
||||
});
|
||||
|
||||
test('comparison rejects boolean as non-numeric', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:true}))"' },
|
||||
{ id: 'check', command: 'echo "yes"', when: '$data.json.val > 0' },
|
||||
{ id: 'fallback', command: 'echo "no"' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['no\n']);
|
||||
});
|
||||
|
||||
test('comparison rejects null as non-numeric', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({val:null}))"' },
|
||||
{ id: 'check', command: 'echo "yes"', when: '$data.json.val >= 0' },
|
||||
{ id: 'fallback', command: 'echo "no"' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['no\n']);
|
||||
});
|
||||
|
||||
test('existing == and != still work with new operators', async () => {
|
||||
const result = await runWorkflow({
|
||||
steps: [
|
||||
{ id: 'data', command: 'node -e "process.stdout.write(JSON.stringify({status:\\"ok\\"}))"' },
|
||||
{ id: 'check', command: 'echo "good"', when: '$data.json.status == "ok"' },
|
||||
],
|
||||
});
|
||||
assert.equal(result.status, 'ok');
|
||||
assert.deepEqual(result.output, ['good\n']);
|
||||
});
|
||||
Reference in New Issue
Block a user