feat: add comparison operators for workflow conditions

This commit is contained in:
Vignesh Natarajan
2026-04-11 15:33:39 -07:00
parent 425198e09d
commit 05e34d741f
3 changed files with 184 additions and 1 deletions
+1
View File
@@ -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
View File
@@ -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;
+139
View File
@@ -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']);
});