From 05e34d741f49811f5a62f567bbeffcc2fa2ea0f0 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 11 Apr 2026 15:33:39 -0700 Subject: [PATCH] feat: add comparison operators for workflow conditions --- CHANGELOG.md | 1 + src/workflows/file.ts | 45 +++++++++- test/condition_comparison.test.ts | 139 ++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 test/condition_comparison.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f1befe..edd9f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/workflows/file.ts b/src/workflows/file.ts index c2a437f..a599e2a 100644 --- a/src/workflows/file.ts +++ b/src/workflows/file.ts @@ -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 { 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; diff --git a/test/condition_comparison.test.ts b/test/condition_comparison.test.ts new file mode 100644 index 0000000..87ad95e --- /dev/null +++ b/test/condition_comparison.test.ts @@ -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']); +});