Skip to content
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`. There is **no deterministic mode on the wire**: a condition step is deterministic iff it carries `preRecordedArgs.optionConditions`. A deterministic gateway publishes with `aiDecision` stripped, so it arrives as `Manual` — an executor blind to the args degrades to a visible manual decision, never a silent AI one.

- **Deterministic Condition** (`condition-step-executor.ts` + `deterministic-condition-evaluator.ts`, PRD-472) — evaluates build-time `preRecordedArgs.optionConditions` (wire-final operator names in `CONDITION_OPERATORS`; a value-bearing operator missing its `value` is rejected at the schema boundary — comparing against `undefined` can never be met, so the step would route to the fallback instead of surfacing the broken config) against Get Data outputs in the run history (`sourceStepId` + `fieldName` → read-record `executionResult.fields`). Top-to-bottom, first-match-wins, `and`/`or` aggregator; no match → `fallbackOption`. **A resolved value that is null = `met: null` = not met, never an error** (data must not fail a decision) — but an **unresolvable reference throws** `ConditionSourceNotLoadedError`: "no value was read" is a broken config, not data, and routing to the fallback would report a decision as taken when its input never arrived. Build-time validation cannot cover it, because a Get Data step may let the AI pick its fields. Same choice as every other step type (`FieldNotFoundError`, `RelationNotFoundError`, `ActionNotFoundError`). 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); an impossible calendar date is **not a date** (`Date.parse('2026-02-30')` rolls over to 2026-03-02, which would make a nonsensical config compare equal to a real value); 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
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import toUpdateStepRequest from './step-outcome-to-update-step-mapper';
import withRetry from './with-retry';
import {
DomainValidationError,
HydrationFailedError,
InvalidStepDefinitionError,
MalformedRunError,
WorkflowExecutorError,
Expand Down Expand Up @@ -71,14 +72,20 @@ export default class ForestServerWorkflowPort implements WorkflowPort {
const dispatch = this.toDispatch(run);
if (dispatch) pending.push(dispatch);
} catch (error) {
if (error instanceof WorkflowExecutorError) {
malformed.push(this.toMalformedInfo(run, error));
} else {
this.logger('Error', 'Failed to hydrate pending run — unexpected error', {
runId: run.id,
error: extractErrorMessage(error),
});
}
// Reported whatever it is: an unreported failure leaves the run pending, so every poll
// returns it and fails again.
this.logger('Error', 'Failed to hydrate pending run', {
runId: run.id,
error: extractErrorMessage(error),
});
malformed.push(
this.toMalformedInfo(
run,
error instanceof WorkflowExecutorError
? error
: new HydrationFailedError(extractErrorMessage(error)),
),
);
}
}

Expand Down Expand Up @@ -137,7 +144,8 @@ export default class ForestServerWorkflowPort implements WorkflowPort {
run: ServerHydratedWorkflowRun,
err: WorkflowExecutorError,
): MalformedRunInfo {
const pending = run.workflowHistory.at(-1) ?? null;
const history = Array.isArray(run.workflowHistory) ? run.workflowHistory : [];
const pending = history.at(-1) ?? null;

return {
runId: String(run.id),
Expand Down
17 changes: 16 additions & 1 deletion packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase {
executionType:
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.AutomatedWithConfirmation;
// Deterministic build-time config. Validated by the step-definition schema.
// Validated by the step-definition schema.
preRecordedArgs?: { selectedRecordStepId?: string; relationName?: string };
}

Expand Down Expand Up @@ -125,6 +125,21 @@ export interface ServerWorkflowCondition extends ServerWorkflowStepBase {
executionType: ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated;
prompt: string | null;
automaticCompletion: false;
// Parsed server-side from `forest:optionConditions` (flowId → answer). Its presence is what makes
// the gateway deterministic.
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
22 changes: 22 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,28 @@ export class InvalidStepDefinitionError extends WorkflowExecutorError {
}
}

export class HydrationFailedError extends WorkflowExecutorError {
constructor(detail: string) {
super(
`Failed to hydrate run: ${detail}`,
'This workflow run could not be prepared for execution. Please contact support.',
);
}
}

// A deterministic condition reads a field a Get Data step was supposed to have loaded. Build-time
// validation cannot always catch this: when that step lets the AI pick its fields, nobody knows
// which ones it will return until the run. Treating it as "not met" would route to the fallback and
// report success — the decision would look taken when its input never arrived.
export class ConditionSourceNotLoadedError extends WorkflowExecutorError {
constructor(fieldName: string, sourceStepId: string) {
super(
`Condition reads "${fieldName}" from step "${sourceStepId}", which did not load it`,
`This decision compares the field "${fieldName}", but the step it reads from did not load that field. Add it to that step's fields, or remove the condition.`,
);
}
}

// Thrown when zod validation fails on a domain object produced internally (e.g. by the
// run-to-pending-step mapper). Distinct from InvalidStepDefinitionError (which flags wire-format
// bugs coming from the orchestrator) so the two can be triaged separately in Sentry.
Expand Down
125 changes: 122 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,28 @@
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,
DeterministicConditionStep,
} 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 {
ConditionSourceNotLoadedError,
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,
isDeterministicConditionStep,
} from '../types/validated/step-definition';

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

if (isDeterministicConditionStep(step)) {
// The config wins over an explicit human action, so say so: a mixed-version fleet can pause
// the step on an args-blind instance, and the user's click would otherwise vanish untraced.
if (incomingPendingData !== undefined) {
this.context.logger(
'Warn',
'Ignoring a submitted option: this decision is evaluated from its conditions',
this.logCtx,
);
}

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 +120,102 @@
return this.buildOutcomeResult({ status: 'success', selectedOption });
}

private async evaluateDeterministically(
step: DeterministicConditionStep,
): Promise<StepExecutionResult> {
const { optionConditions, fallbackOption } = step.preRecordedArgs;
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);

// A missing *reference* is a broken config, not data: routing to the fallback would report a
// decision as taken when its input never arrived. Every other step type already throws here
// (FieldNotFoundError, RelationNotFoundError, ActionNotFoundError) — this one used to be the
// exception. A value that is present but null still counts as not met, per the spec.
if (!resolved.found) {
throw new ConditionSourceNotLoadedError(condition.fieldName, condition.sourceStepId);
}

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 218 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
Loading
Loading