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
5 changes: 4 additions & 1 deletion packages/workflow-executor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p

## Step types & execution modes

`StepExecutionMode` (domain enum, mapped from the server contract in `step-definition-mapper.ts`): `Manual`, `AutomatedWithConfirmation`, `FullyAutomated`.
`StepExecutionMode` (domain enum, mapped from the server contract in `step-definition-mapper.ts`): `Manual`, `AutomatedWithConfirmation`, `FullyAutomated`, `Deterministic` (condition steps only).

- **Deterministic Condition** (`condition-step-executor.ts` + `deterministic-condition-evaluator.ts`, PRD-472) — evaluates build-time `preRecordedArgs.optionConditions` (schema-required when the mode is deterministic; wire-final operator names in `CONDITION_OPERATORS`) against Get Data outputs in the run history (`sourceStepId` + `fieldName` → read-record `executionResult.fields`). Top-to-bottom, first-match-wins, `and`/`or` aggregator; unresolvable/null value = `met: null` = not met (**never an error**); no match → `fallbackOption`. Never calls AI, never awaits input (`incomingPendingData` is ignored — no user override). The selected option (match or fallback) is checked against `step.options` before persisting → `InvalidStepDefinitionError`, because `optionConditions` and `options` come from two different server-side derivations and an unroutable option must not travel as a "success". Evaluation trace persisted in `executionParams` (`evaluations`/`selectedOption`/`usedFallback`) for the run view; the fallback never appears in `evaluations`.
- Comparison semantics (`deterministic-condition-evaluator.ts`): a strictly numeric string is coerced **against a real number only** (Sequelize returns `numeric`/`decimal`/`bigint` as strings while the datasource types them `Number`); an offset-less ISO datetime is read as **UTC** (host-TZ parsing would make the same run route differently per machine); a type mismatch satisfies no operator, negated ones included (`not_equal(true, 'true')` = not met); `contains`/`not_contains` are strings-only, per the contract.

- **Trigger Action** (`trigger-record-action-step-executor.ts`, `handleFirstCall`) — detects the form via `getActionForm` (full field list, not `getActionFormInfo`). Formless: `FullyAutomated` runs it *in the executor* via the audited agent; otherwise pauses. With a form: `Manual` pauses with the native form (no AI fill); `AutomatedWithConfirmation` AI-fills then pauses for the user to submit natively; `FullyAutomated` AI-fills (`fillFormWithAi`) and, if `filledForm.canExecute`, submits in the executor — falling back to `awaiting-input` (pause) when required fields are missing, or on `ActionFormValidationError`/`ActionRequiresApprovalError` (a human can finish those). `UnsupportedActionFormError` is declared/exported but **never thrown** in src.
- **Pre-recorded args** — record steps accept `preRecordedArgs` to skip AI. Technical names (`fieldName`/`fieldNames`/`actionName`/`relationName`) are matched exactly via `findFieldByTechnicalName` (no fuzz); `resolveAiFieldName` (exact-then-normalized) is reserved for AI-returned display names. The source record differs by step: read-record/update-record use `selectedRecordStepIndex` (a runtime index, resolved in `resolveRecordRef`); trigger-action/load-related-record use `selectedRecordStepId` — a **stable BPMN step id** (or `WORKFLOW_START_STEP_ID` sentinel) resolved by `resolveSourceRecordRef`, chosen to survive the index shifts a revision causes. Partial args supported. Unresolvable → `FieldNotFoundError`/`ActionNotFoundError`/`RelationNotFoundError`; bad shape / out-of-range index → `InvalidPreRecordedArgsError`.
Expand Down
21 changes: 20 additions & 1 deletion packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export enum ServerStepExecutionTypeEnum {
Manual = 'manual',
AutomatedWithConfirmation = 'automated-with-confirmation',
FullyAutomated = 'fully-automated',
Deterministic = 'deterministic',
}

interface ServerWorkflowStepBase {
Expand Down Expand Up @@ -122,9 +123,27 @@ export interface ServerWorkflowEnd extends ServerWorkflowStepBase {

export interface ServerWorkflowCondition extends ServerWorkflowStepBase {
type: ServerStepTypeEnum.Condition;
executionType: ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated;
executionType:
| ServerStepExecutionTypeEnum.Manual
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.Deterministic;
prompt: string | null;
automaticCompletion: false;
// Parsed from `forest:optionConditions` server-side (flowId → answer). Present when
// executionType is deterministic. Validated by the step-definition schema.
preRecordedArgs?: {
optionConditions: Array<{
option: string;
aggregator: 'and' | 'or';
conditions: Array<{
sourceStepId: string;
fieldName: string;
operator: string;
value?: unknown;
}>;
}>;
fallbackOption: string;
};
}

export interface ServerWorkflowEscalation extends ServerWorkflowStepBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti
executionType: condition.executionType,
title: condition.title,
options,
preRecordedArgs: condition.preRecordedArgs,
});
}

Expand Down
104 changes: 101 additions & 3 deletions packages/workflow-executor/src/executors/condition-step-executor.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import type { StepExecutionResult } from '../types/execution-context';
import type { ConditionStepDefinition } from '../types/validated/step-definition';
import type { ConditionEvaluation, StepExecutionData } from '../types/step-execution-data';
import type {
ConditionStepDefinition,
DeterministicCondition,
} from '../types/validated/step-definition';
import type { ConditionStepOutcome } from '../types/validated/step-outcome';

import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy';
import { z } from 'zod';

import { StepStateError } from '../errors';
import { InvalidStepDefinitionError, StepStateError } from '../errors';
import BaseStepExecutor from './base-step-executor';
import evaluateOperator from './deterministic-condition-evaluator';
import patchBodySchemas from '../http/pending-data-validators';
import { StepExecutionMode } from '../types/validated/step-definition';
import { StepExecutionMode, StepType } from '../types/validated/step-definition';

interface GatewayToolArgs {
option: string | null;
Expand Down Expand Up @@ -62,6 +67,12 @@
protected async doExecute(): Promise<StepExecutionResult> {
const { stepDefinition: step, incomingPendingData } = this.context;

// Deterministic mode: pure evaluation of build-time conditions against the run's step
// history — never calls the AI, never awaits input.
if (step.executionType === StepExecutionMode.Deterministic) {
return this.evaluateDeterministically(step);
}

// Manual mode: the user picks the option from the frontend. Wait for their input
// without ever calling the AI.
const isManual = step.executionType === StepExecutionMode.Manual;
Expand Down Expand Up @@ -92,11 +103,98 @@
return this.buildOutcomeResult({ status: 'success', selectedOption });
}

private async evaluateDeterministically(
step: ConditionStepDefinition,
): Promise<StepExecutionResult> {
// Guaranteed by the schema's superRefine for the deterministic mode.
const { optionConditions, fallbackOption } = step.preRecordedArgs!;

Check warning on line 110 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
const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId);

let matchedOption: string | undefined;
const evaluations = optionConditions.map(({ option, aggregator, conditions }) => {
if (matchedOption !== undefined) {
return { option, outcome: 'not-evaluated' } satisfies ConditionEvaluation;
}

const results = conditions.map((condition, index) => ({
index,
met: this.evaluateCondition(condition, stepExecutions),
}));
const matched =
aggregator === 'or'
? results.some(result => result.met === true)
: results.every(result => result.met === true);
if (matched) matchedOption = option;

return {
option,
outcome: matched ? 'matched' : 'not-matched',
conditions: results,
} satisfies ConditionEvaluation;
});

const usedFallback = matchedOption === undefined;
const selectedOption = matchedOption ?? fallbackOption;

// optionConditions and options come from two different server-side derivations; an option the
// orchestrator cannot route must fail here, not silently succeed and break the run downstream.
if (!step.options.includes(selectedOption)) {
const allowed = step.options.join(', ');
throw new InvalidStepDefinitionError(
`deterministic option "${selectedOption}" is not a valid choice (expected one of: ${allowed})`,
);
}

await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'condition',
stepIndex: this.context.stepIndex,
executionParams: { evaluations, selectedOption, usedFallback },
executionResult: { answer: selectedOption },
});

return this.buildOutcomeResult({ status: 'success', selectedOption });
}

private evaluateCondition(
condition: DeterministicCondition,
stepExecutions: StepExecutionData[],
): boolean | null {
const resolved = this.resolveConditionValue(condition, stepExecutions);
// Unresolvable reference (step never ran, field not read, read error) → not evaluable, even
// for present/blank — a value that was never read is not the same as a blank one.
if (!resolved.found) return null;

return evaluateOperator(condition.operator, resolved.value, condition.value);
}

// Same live-path + most-recent-occurrence resolution as resolveSourceRecordRef: previousSteps
// are already restricted to the live path, and in a loop the same step id repeats.
private resolveConditionValue(
condition: DeterministicCondition,
stepExecutions: StepExecutionData[],
): { found: true; value: unknown } | { found: false } {
const matches = this.context.previousSteps.filter(
step =>
step.stepDefinition.type === StepType.ReadRecord &&
step.stepOutcome.stepId === condition.sourceStepId,
);
const sourceStep = matches[matches.length - 1];
if (!sourceStep) return { found: false };

const execution = this.resolveStepExecution(sourceStep, stepExecutions);
if (execution?.type !== 'read-record') return { found: false };

const field = execution.executionResult.fields.find(f => f.name === condition.fieldName);
if (!field || !('value' in field)) return { found: false };

return { found: true, value: field.value };
}

private readUserChoice(
step: ConditionStepDefinition,
incomingPendingData: unknown,
): GatewayDecision {
const parsed = patchBodySchemas.condition!.safeParse(incomingPendingData);

Check warning on line 197 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { ConditionOperator } from '../types/validated/step-definition';

// Guard against Date.parse's laxity ("5" parses as a year in some engines): only strings that
// start like an ISO date are treated as dates.
const ISO_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/;
const TIMEZONE_SUFFIX = /(Z|[+-]\d{2}:?\d{2})$/i;
// Sequelize hands back numeric/decimal/bigint columns as strings although datasource-sequelize
// maps them to the Number primitive, so the builder's JSON number meets a string at runtime.
const NUMERIC_STRING = /^-?\d+(\.\d+)?$/;

function toTimestamp(value: unknown): number | null {
if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;

// Date.parse reads an offset-less datetime as host-local (date-only as UTC), which would route
// the same run differently per machine — pin every offset-less datetime to UTC.
const absolute = value.includes('T') && !TIMEZONE_SUFFIX.test(value) ? `${value}Z` : value;
const parsed = Date.parse(absolute);

return Number.isNaN(parsed) ? null : parsed;
Comment on lines +11 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High executors/deterministic-condition-evaluator.ts:7

toTimestamp accepts invalid calendar dates such as 2026-02-30 and converts them to normalized timestamps, so malformed values compare equal to 2026-03-02 and can satisfy equality, membership, or ordering conditions. Validate that the parsed date round-trips to the original calendar date before returning its timestamp.

-function toTimestamp(value: unknown): number | null {
-  if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;
-
-  const parsed = Date.parse(value);
-
-  return Number.isNaN(parsed) ? null : parsed;
-}
+function toTimestamp(value: unknown): number | null {
+  if (typeof value !== 'string' || !ISO_DATE_PREFIX.test(value)) return null;
+
+  const datePart = value.slice(0, 10);
+  const normalized = new Date(`${datePart}T00:00:00.000Z`);
+  if (Number.isNaN(normalized.getTime()) || normalized.toISOString().slice(0, 10) !== datePart) {
+    return null;
+  }
+
+  const parsed = Date.parse(value);
+
+  return Number.isNaN(parsed) ? null : parsed;
+}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/deterministic-condition-evaluator.ts around lines 7-12:

`toTimestamp` accepts invalid calendar dates such as `2026-02-30` and converts them to normalized timestamps, so malformed values compare equal to `2026-03-02` and can satisfy equality, membership, or ordering conditions. Validate that the parsed date round-trips to the original calendar date before returning its timestamp.

}

function toNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isNaN(value) ? null : value;

return typeof value === 'string' && NUMERIC_STRING.test(value) ? Number(value) : null;
}

// Coercion only kicks in against a real number (always the build-time side): two numeric-looking
// strings stay strings, since the contract exposes ordering operators for Number/Date fields only.
function toNumberPair(actual: unknown, expected: unknown): [number, number] | null {
if (typeof actual !== 'number' && typeof expected !== 'number') return null;

const actualNumber = toNumber(actual);
const expectedNumber = toNumber(expected);

return actualNumber !== null && expectedNumber !== null ? [actualNumber, expectedNumber] : null;
}

function scalarEqual(actual: unknown, expected: unknown): boolean | null {
if (actual === expected) return true;

const numbers = toNumberPair(actual, expected);
if (numbers) return numbers[0] === numbers[1];

const actualTs = toTimestamp(actual);
const expectedTs = toTimestamp(expected);
if (actualTs !== null && expectedTs !== null) return actualTs === expectedTs;

return typeof actual === typeof expected ? false : null;
}

function isEqual(actual: unknown, expected: unknown): boolean | null {
if (Array.isArray(actual) && Array.isArray(expected)) {
return (
actual.length === expected.length &&
actual.every((item, index) => scalarEqual(item, expected[index]) === true)
);
}

if (Array.isArray(actual) || Array.isArray(expected)) return null;

return scalarEqual(actual, expected);
}

function compare(actual: unknown, expected: unknown): number | null {
const numbers = toNumberPair(actual, expected);
if (numbers) return numbers[0] - numbers[1];

const actualTs = toTimestamp(actual);
const expectedTs = toTimestamp(expected);
if (actualTs !== null && expectedTs !== null) return actualTs - expectedTs;

return null;
}

function isPresent(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.length > 0;
if (Array.isArray(value)) return value.length > 0;

return true;
}

function isMemberOf(list: unknown, candidate: unknown): boolean {
return Array.isArray(list) && list.some(item => scalarEqual(item, candidate) === true);
}

function ordering(satisfies: (diff: number) => boolean) {
return (actual: unknown, expected: unknown): boolean => {
const diff = compare(actual, expected);

return diff !== null && satisfies(diff);
};
}

const EVALUATORS: Record<
Exclude<ConditionOperator, 'present' | 'blank'>,
(actual: unknown, expected: unknown) => boolean
> = {
equal: (actual, expected) => isEqual(actual, expected) === true,
not_equal: (actual, expected) => isEqual(actual, expected) === false,
greater_than: ordering(diff => diff > 0),
less_than: ordering(diff => diff < 0),
greater_than_or_equal: ordering(diff => diff >= 0),
less_than_or_equal: ordering(diff => diff <= 0),
in: (actual, expected) => isMemberOf(expected, actual),
not_in: (actual, expected) => Array.isArray(expected) && !isMemberOf(expected, actual),
contains: (actual, expected) =>
typeof actual === 'string' && typeof expected === 'string' && actual.includes(expected),
not_contains: (actual, expected) =>
typeof actual === 'string' && typeof expected === 'string' && !actual.includes(expected),
};

/**
* Pure evaluation of one deterministic condition. Never throws for data reasons:
* - `null` = not evaluable (the resolved value is null/missing) — treated as "not met";
* - a type-mismatched comparison (including for negated operators) is "not met" (`false`),
* so a broken config can never accidentally satisfy a condition.
*/
export default function evaluateOperator(
operator: ConditionOperator,
actual: unknown,
expected: unknown,
): boolean | null {
if (operator === 'present') return isPresent(actual);
if (operator === 'blank') return !isPresent(actual);
if (actual === null || actual === undefined) return null;

return EVALUATORS[operator](actual, expected);
}
19 changes: 18 additions & 1 deletion packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,26 @@ export interface WithUserConfirmation<T extends Record<string, unknown> = Record

// -- Condition --

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. */
conditions?: Array<{ index: number; met: boolean | null }>;
}

// Deterministic evaluation trace read by the run view (PRD-472 contract shape). The fallback
// never appears in `evaluations` — the front derives its display from `usedFallback`.
export interface DeterministicConditionExecutionParams {
evaluations: ConditionEvaluation[];
selectedOption: string;
usedFallback: boolean;
}

export interface ConditionStepExecutionData extends BaseStepExecutionData {
type: 'condition';
executionParams: { answer: string | null; reasoning?: string };
executionParams:
| { answer: string | null; reasoning?: string }
| DeterministicConditionExecutionParams;
executionResult?: { answer: string };
}

Expand Down
Loading
Loading