diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index fbd17fc2df..4b4461b694 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -124,6 +124,10 @@ export interface ServerWorkflowEnd extends ServerWorkflowStepBase { outgoing: []; } +export type ServerWorkflowConditionNode = + | { sourceStepId: string; fieldName: string; operator: string; value?: unknown } + | { aggregator: 'and' | 'or'; conditions: ServerWorkflowConditionNode[] }; + export interface ServerWorkflowCondition extends ServerWorkflowStepBase { type: ServerStepTypeEnum.Condition; executionType: ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated; @@ -135,12 +139,7 @@ export interface ServerWorkflowCondition extends ServerWorkflowStepBase { optionConditions: Array<{ option: string; aggregator: 'and' | 'or'; - conditions: Array<{ - sourceStepId: string; - fieldName: string; - operator: string; - value?: unknown; - }>; + conditions: ServerWorkflowConditionNode[]; }>; fallbackOption: string; }; diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 422c506b3f..83355be618 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -2,11 +2,13 @@ import type { StepExecutionResult } from '../types/execution-context'; import type { ConditionEvaluation, ConditionNotLoadedReason, + ConditionResult, StepExecutionData, } from '../types/step-execution-data'; import type { ConditionStepDefinition, DeterministicCondition, + DeterministicConditionGroup, DeterministicConditionStep, } from '../types/validated/step-definition'; import type { ConditionStepOutcome, ErrorKind } from '../types/validated/step-outcome'; @@ -21,6 +23,7 @@ import patchBodySchemas from '../http/pending-data-validators'; import { StepExecutionMode, StepType, + isDeterministicConditionGroup, isDeterministicConditionStep, } from '../types/validated/step-definition'; @@ -136,15 +139,11 @@ export default class ConditionStepExecutor extends BaseStepExecutor { - const { met, reason } = this.evaluateCondition(condition, stepExecutions, clock); - - return { index, met, ...(reason && { reason }) }; - }); - const matched = - aggregator === 'or' - ? results.some(result => result.met === true) - : results.every(result => result.met === true); + const { met: matched, conditions: results } = this.evaluateGroup( + { aggregator, conditions }, + stepExecutions, + clock, + ); if (matched) matchedOption = option; return { @@ -182,6 +181,30 @@ export default class ConditionStepExecutor extends BaseStepExecutor { + if (isDeterministicConditionGroup(node)) { + return { index, ...this.evaluateGroup(node, stepExecutions, clock) }; + } + + const { met, reason } = this.evaluateCondition(node, stepExecutions, clock); + + return { index, met, ...(reason && { reason }) }; + }); + + return { + met: + aggregator === 'or' + ? results.some(result => result.met === true) + : results.every(result => result.met === true), + conditions: results, + }; + } + private evaluateCondition( condition: DeterministicCondition, stepExecutions: StepExecutionData[], diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index dbc63df1eb..4fa29e8e87 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -31,15 +31,24 @@ export interface WithUserConfirmation = Record // and the run view is what words this for the operator. export type ConditionNotLoadedReason = 'source-step-not-reached' | 'field-not-loaded'; +/** + * `index` is the position in the definition's conditions at the same level. `met: null` = the + * value could not be evaluated. `reason` accompanies `met: null` only when nothing was read at all: + * a value read as null carries none, which is how the run view tells the two apart. A group + * carries its own result, so the run view never re-derives the aggregation, and its children's. + */ +export interface ConditionResult { + index: number; + met: boolean | null; + reason?: ConditionNotLoadedReason; + conditions?: ConditionResult[]; +} + export interface ConditionEvaluation { option: string; outcome: 'matched' | 'not-matched' | 'not-evaluated'; - /** - * Absent when outcome is 'not-evaluated'. `met: null` = the value could not be evaluated. - * `reason` accompanies `met: null` only when nothing was read at all: a value read as null - * carries none, which is how the run view tells the two apart. - */ - conditions?: Array<{ index: number; met: boolean | null; reason?: ConditionNotLoadedReason }>; + /** Absent when outcome is 'not-evaluated'. */ + conditions?: ConditionResult[]; } // Deterministic evaluation trace read by the run view (PRD-472 contract shape). The fallback diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 9492202b57..a6133ea147 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -118,10 +118,34 @@ const DeterministicConditionSchema = z }); export type DeterministicCondition = z.infer; +const ConditionAggregatorSchema = z.enum(['and', 'or']); + +export interface DeterministicConditionGroup { + aggregator: z.infer; + conditions: DeterministicConditionNode[]; +} +export type DeterministicConditionNode = DeterministicCondition | DeterministicConditionGroup; + +// An empty group would read as "match nothing" (or) or "match everything" (and): rejected, never +// evaluated. No depth limit here: the orchestrator parser refuses more than 10 levels. +const DeterministicConditionNodeSchema: z.ZodType = z.lazy(() => + z.union([ + z.object({ + aggregator: ConditionAggregatorSchema, + conditions: z.array(DeterministicConditionNodeSchema).min(1), + }), + DeterministicConditionSchema, + ]), +); + +export const isDeterministicConditionGroup = ( + node: DeterministicConditionNode, +): node is DeterministicConditionGroup => 'aggregator' in node; + const OptionConditionsSchema = z.object({ option: z.string().min(1), - aggregator: z.enum(['and', 'or']), - conditions: z.array(DeterministicConditionSchema).min(1), + aggregator: ConditionAggregatorSchema, + conditions: z.array(DeterministicConditionNodeSchema).min(1), }); export type OptionConditions = z.infer; diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts index d5330515ca..1ded754f3e 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -1087,6 +1087,112 @@ describe('ConditionStepExecutor', () => { }, ); + describe('with a group of conditions', () => { + // risk = high AND (country in [FR, BE] OR amount > 10000) + const escalationArgs: ConditionPreRecordedArgs = { + optionConditions: [ + { + option: 'Escalate', + aggregator: 'and', + conditions: [ + { sourceStepId: 'get-1', fieldName: 'risk', operator: 'equal', value: 'high' }, + { + aggregator: 'or', + conditions: [ + { + sourceStepId: 'get-1', + fieldName: 'country', + operator: 'in', + value: ['FR', 'BE'], + }, + { + sourceStepId: 'get-1', + fieldName: 'amount', + operator: 'greater_than', + value: 10000, + }, + ], + }, + ], + }, + ], + fallbackOption: 'Other', + }; + + it('matches when the group is met through one of its conditions', async () => { + const { context, runStore } = makeDeterministicContext(escalationArgs, [ + { name: 'risk', displayName: 'Risk', value: 'high' }, + { name: 'country', displayName: 'Country', value: 'DE' }, + { name: 'amount', displayName: 'Amount', value: 25000 }, + ]); + + const result = await new ConditionStepExecutor(context).execute(); + + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Escalate'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionParams: expect.objectContaining({ + evaluations: [ + { + option: 'Escalate', + outcome: 'matched', + conditions: [ + { index: 0, met: true }, + { + index: 1, + met: true, + conditions: [ + { index: 0, met: false }, + { index: 1, met: true }, + ], + }, + ], + }, + ], + }), + }), + ); + }); + + it('records the group as not met, with the reason on the condition it could not read', async () => { + const { context, runStore } = makeDeterministicContext(escalationArgs, [ + { name: 'risk', displayName: 'Risk', value: 'high' }, + { name: 'country', displayName: 'Country', value: 'DE' }, + { name: 'amount', displayName: 'Amount', error: 'Field not found: amount' }, + ]); + + const result = await new ConditionStepExecutor(context).execute(); + + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Other'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionParams: expect.objectContaining({ + evaluations: [ + { + option: 'Escalate', + outcome: 'not-matched', + conditions: [ + { index: 0, met: true }, + { + index: 1, + met: false, + conditions: [ + { index: 0, met: false }, + { index: 1, met: null, reason: 'field-not-loaded' }, + ], + }, + ], + }, + ], + usedFallback: true, + }), + }), + ); + }); + }); + it('compares a decimal column returned as a string against the builder number', async () => { const { context } = makeDeterministicContext(amountArgs, [ { name: 'amount', displayName: 'Amount', value: '150.00' }, diff --git a/packages/workflow-executor/test/types/step-definition.test.ts b/packages/workflow-executor/test/types/step-definition.test.ts index 028532ce3c..5c7a376027 100644 --- a/packages/workflow-executor/test/types/step-definition.test.ts +++ b/packages/workflow-executor/test/types/step-definition.test.ts @@ -346,3 +346,61 @@ describe('GuidanceStepDefinitionSchema executionType', () => { ).toBe(false); }); }); + +describe('ConditionStepDefinitionSchema condition groups', () => { + const base = { type: StepType.Condition as const, options: ['Escalate', 'Otherwise'] }; + const risk = { sourceStepId: 'get-kyc', fieldName: 'risk', operator: 'equal', value: 'high' }; + const country = { + sourceStepId: 'get-kyc', + fieldName: 'country', + operator: 'in', + value: ['FR', 'BE'], + }; + const parseOption = (conditions: unknown[]) => + ConditionStepDefinitionSchema.safeParse({ + ...base, + preRecordedArgs: { + optionConditions: [{ option: 'Escalate', aggregator: 'and', conditions }], + fallbackOption: 'Otherwise', + }, + }); + + it('accepts a group among the conditions of an option and round-trips it', () => { + const conditions = [risk, { aggregator: 'or', conditions: [country, risk] }]; + + const result = parseOption(conditions); + + expect(result.success && result.data.preRecordedArgs?.optionConditions[0].conditions).toEqual( + conditions, + ); + }); + + it('accepts a group nested in a group, as the stored model has no depth limit', () => { + const result = parseOption([ + { aggregator: 'or', conditions: [risk, { aggregator: 'and', conditions: [country] }] }, + ]); + + expect(result.success).toBe(true); + }); + + it('rejects an empty group, which would read as match nothing or match everything', () => { + const result = parseOption([risk, { aggregator: 'or', conditions: [] }]); + + expect(result.success).toBe(false); + }); + + it('rejects a group with an unknown aggregator', () => { + const result = parseOption([{ aggregator: 'xor', conditions: [risk] }]); + + expect(result.success).toBe(false); + }); + + it('applies the condition rules inside a group', () => { + const result = parseOption([ + { aggregator: 'or', conditions: [{ ...country, operator: 'equal', value: undefined }] }, + ]); + + expect(result.success).toBe(false); + expect(JSON.stringify(!result.success && result.error.issues)).toContain('value is required'); + }); +});