Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
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';
Expand All @@ -21,6 +23,7 @@
import {
StepExecutionMode,
StepType,
isDeterministicConditionGroup,
isDeterministicConditionStep,
} from '../types/validated/step-definition';

Expand Down Expand Up @@ -136,15 +139,11 @@
return { option, outcome: 'not-evaluated' } satisfies ConditionEvaluation;
}

const results = conditions.map((condition, index) => {
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 {
Expand Down Expand Up @@ -182,6 +181,30 @@
return this.buildOutcomeResult({ status: 'success', selectedOption });
}

private evaluateGroup(
{ aggregator, conditions }: DeterministicConditionGroup,
stepExecutions: StepExecutionData[],
clock: Clock,
): { met: boolean; conditions: ConditionResult[] } {
const results = conditions.map((node, index): ConditionResult => {
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[],
Expand Down Expand Up @@ -233,7 +256,7 @@
step: ConditionStepDefinition,
incomingPendingData: unknown,
): GatewayDecision {
const parsed = patchBodySchemas.condition!.safeParse(incomingPendingData);

Check warning on line 259 in packages/workflow-executor/src/executors/condition-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion

if (!parsed.success) {
throw new StepStateError(
Expand Down
21 changes: 15 additions & 6 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,24 @@ export interface WithUserConfirmation<T extends Record<string, unknown> = 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
Expand Down
28 changes: 26 additions & 2 deletions packages/workflow-executor/src/types/validated/step-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,34 @@ const DeterministicConditionSchema = z
});
export type DeterministicCondition = z.infer<typeof DeterministicConditionSchema>;

const ConditionAggregatorSchema = z.enum(['and', 'or']);

export interface DeterministicConditionGroup {
aggregator: z.infer<typeof ConditionAggregatorSchema>;
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<DeterministicConditionNode> = 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<typeof OptionConditionsSchema>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
58 changes: 58 additions & 0 deletions packages/workflow-executor/test/types/step-definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading