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
19 changes: 11 additions & 8 deletions packages/workflow-executor/CLAUDE.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/workflow-executor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"jsonwebtoken": "^9.0.3",
"koa": "^3.0.1",
"koa-jwt": "^4.0.4",
"luxon": "^3.2.1",
"pg": "^8.8.0",
"picocolors": "^1.1.1",
"sequelize": "^6.37.8",
Expand All @@ -47,6 +48,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^2.13.5",
"@types/koa__router": "^12.0.4",
"@types/luxon": "^3.2.0",
"@types/sequelize": "^6.12.0",
"sqlite3": "^6.0.1",
"supertest": "^7.1.3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
StepOutcome,
} from '../types/validated/step-outcome';

import { IANAZone } from 'luxon';
import { z } from 'zod';

import { deserializeRecordId } from './record-id-serializer';
Expand Down Expand Up @@ -178,6 +179,11 @@ export default function toAvailableStepExecution(
stepDefinition: toStepDefinition(pending.stepDefinition),
previousSteps: toPreviousSteps(run.workflowHistory, pending.stepIndex),
user: toStepUser(run.id, run.userProfile),
// UTC when the project has none set, when the orchestrator is too old to send one, and when
// the name is not a zone Luxon knows: a relative date must resolve the same on every executor
// instance, so the machine's zone is never the fallback. The zone actually used is persisted
// with the evaluation, so the run view shows UTC rather than the name it fell back from.
timezone: run.timezone && IANAZone.isValidZone(run.timezone) ? run.timezone : 'UTC',
};

// Defense against mapper bugs: zod asserts the shape we produce is what the domain expects,
Expand Down
2 changes: 2 additions & 0 deletions packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ export interface ServerHydratedWorkflowRun {
renderingId: number;
lockedAt?: string | null;
userProfile: ServerUserProfile;
/** The project's IANA zone. Absent from an orchestrator that predates it, null when unset. */
timezone?: string | null;
}

// --- Update step request (POST /api/workflow-orchestrator/update-step) ---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { InvalidStepDefinitionError, StepStateError } from '../errors';
import BaseStepExecutor from './base-step-executor';
import evaluateOperator from './deterministic-condition-evaluator';
import evaluateOperator, { type Clock } from './deterministic-condition-evaluator';
import patchBodySchemas from '../http/pending-data-validators';
import {
StepExecutionMode,
Expand Down Expand Up @@ -127,6 +127,8 @@
): Promise<StepExecutionResult> {
const { optionConditions, fallbackOption } = step.preRecordedArgs;
const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId);
// One clock per step, so "today" cannot flip between the first and the last condition.
const clock: Clock = { now: new Date(), timezone: this.context.timezone };

let matchedOption: string | undefined;
const evaluations = optionConditions.map(({ option, aggregator, conditions }) => {
Expand All @@ -135,7 +137,7 @@
}

const results = conditions.map((condition, index) => {
const { met, reason } = this.evaluateCondition(condition, stepExecutions);
const { met, reason } = this.evaluateCondition(condition, stepExecutions, clock);

return { index, met, ...(reason && { reason }) };
});
Expand Down Expand Up @@ -167,7 +169,13 @@
await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'condition',
stepIndex: this.context.stepIndex,
executionParams: { evaluations, selectedOption, usedFallback },
executionParams: {
evaluations,
selectedOption,
usedFallback,
evaluatedAt: clock.now.toISOString(),
timezone: clock.timezone,
},
executionResult: { answer: selectedOption },
});

Expand All @@ -177,6 +185,7 @@
private evaluateCondition(
condition: DeterministicCondition,
stepExecutions: StepExecutionData[],
clock: Clock,
): { met: boolean | null; reason?: ConditionNotLoadedReason } {
const resolved = this.resolveConditionValue(condition, stepExecutions);

Expand All @@ -194,7 +203,7 @@
return { met: null, reason: resolved.reason };
}

return { met: evaluateOperator(condition.operator, resolved.value, condition.value) };
return { met: evaluateOperator(condition.operator, resolved.value, condition.value, clock) };
}

// Same live-path + most-recent-occurrence resolution as resolveSourceRecordRef: previousSteps
Expand Down Expand Up @@ -224,7 +233,7 @@
step: ConditionStepDefinition,
incomingPendingData: unknown,
): GatewayDecision {
const parsed = patchBodySchemas.condition!.safeParse(incomingPendingData);

Check warning on line 236 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
@@ -1,8 +1,17 @@
import type { ConditionOperator } from '../types/validated/step-definition';

import { DateTime } from 'luxon';

// Injected rather than read here so the evaluator stays a pure function of its inputs.
export interface Clock {
now: Date;
timezone: string;
}

// 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 DATE_ONLY = /^\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.
Expand All @@ -22,13 +31,42 @@ function toTimestamp(value: unknown): number | null {
if (!isRealCalendarDate(value.slice(0, 10))) 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;
// the same run differently per machine — pin every offset-less datetime to UTC. Postgres emits
// the SQL form ("2026-09-04 08:00:00+02"): space separator, bare-hour offset that Date.parse
// only accepts as hh:mm once the separator is a T.
const datetime = value
.trim()
.replace(/\s+/, 'T')
.replace(/\s+/g, '')
.replace(/(:\d{2}(?:\.\d+)?)([+-]\d{2})$/, '$1$2:00');
const absolute =
datetime.includes('T') && !TIMEZONE_SUFFIX.test(datetime) ? `${datetime}Z` : datetime;
const parsed = Date.parse(absolute);

return Number.isNaN(parsed) ? null : parsed;
}

// A calendar date has no instant of its own, so it is read at midnight in the project's zone: that
// is what makes "today" the project's today for a Dateonly column, whatever the machine's or UTC's
// day is at that moment. A datetime is an absolute instant already (the UTC pinning above).
function toInstant(value: unknown, timezone: string): DateTime | null {
if (typeof value !== 'string' || !isRealCalendarDate(value.slice(0, 10))) return null;
if (DATE_ONLY.test(value)) return DateTime.fromISO(value, { zone: timezone });

const timestamp = toTimestamp(value);

return timestamp === null ? null : DateTime.fromMillis(timestamp, { zone: timezone });
}

// The toolkit compares a Dateonly column against the bound's calendar date (its time transforms
// format the bound with toISODate for that column type), so a bound falling mid-day is read at the
// start of its day when the value is a calendar date: "past" on a Dateonly excludes today, as the
// list filter does. The kind is told from the value's shape where the toolkit reads the column
// type, so parity holds as long as a Dateonly serialises as a bare YYYY-MM-DD.
function alignToValueKind(actual: unknown, bound: DateTime): DateTime {
return DATE_ONLY.test(actual as string) ? bound.startOf('day') : bound;
}

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

Expand Down Expand Up @@ -119,16 +157,20 @@ function isPresent(value: unknown): boolean {
return true;
}

// Tri-state like scalarEqual: null = the candidate could not be compared to any member, so a
// negated membership test cannot claim "not a member" about a comparison it never managed to make.
// An empty list compares nothing and mismatches nothing, so it stays a plain false.
function memberOf(list: unknown, candidate: unknown): boolean | null {
if (!Array.isArray(list)) return null;
function isMemberOf(list: unknown, candidate: unknown): boolean {
return Array.isArray(list) && list.some(item => scalarEqual(item, candidate) === true);
}

function includesAll(actual: unknown, expected: unknown): boolean {
if (!Array.isArray(actual)) return false;
const wanted = Array.isArray(expected) ? expected : [expected];

const results = list.map(item => scalarEqual(item, candidate));
if (results.includes(true)) return true;
return wanted.length > 0 && wanted.every(item => isMemberOf(actual, item));
}

return results.includes(null) ? null : false;
function stringTest(satisfies: (actual: string, expected: string) => boolean) {
return (actual: unknown, expected: unknown): boolean =>
typeof actual === 'string' && typeof expected === 'string' && satisfies(actual, expected);
}

function ordering(satisfies: (diff: number) => boolean) {
Expand All @@ -139,22 +181,122 @@ function ordering(satisfies: (diff: number) => boolean) {
};
}

// The builder pins a datetime for a Date column and a calendar date for a Dateonly one, so both
// sides share a kind and a calendar date is read in the project's zone on either side. The toolkit
// reads a bare date in the server's zone here; the project's is the deliberate choice.
function dateOrdering(satisfies: (actual: DateTime, expected: DateTime) => boolean) {
return (actual: unknown, expected: unknown, clock: Clock): boolean => {
const actualInstant = toInstant(actual, clock.timezone);
const expectedInstant = toInstant(expected, clock.timezone);

return (
actualInstant !== null &&
expectedInstant !== null &&
satisfies(actualInstant, expectedInstant)
);
};
}

// Relative to the clock. The end of a window is always excluded; its start is included for a
// calendar date and excluded for a datetime. That asymmetry is datasource-toolkit's (its time
// transforms emit GreaterThanOrEqual for a Dateonly column, GreaterThan otherwise), and the list
// filter runs on it, so the same record answers "today" the same way in both places.
type Window = (now: DateTime, value: unknown) => [start: DateTime, end: DateTime] | null;

function days(value: unknown): number | null {
const count = toNumber(value);

return count !== null && Number.isInteger(count) && count > 0 ? count : null;
}

const WINDOWS: Record<
'today' | 'yesterday' | 'previous_x_days' | 'previous_x_days_to_date',
Window
> = {
today: now => [now.startOf('day'), now.plus({ days: 1 }).startOf('day')],
yesterday: now => [now.minus({ days: 1 }).startOf('day'), now.startOf('day')],
previous_x_days: (now, value) => {
const count = days(value);

return count === null ? null : [now.minus({ days: count }).startOf('day'), now.startOf('day')];
},
previous_x_days_to_date: (now, value) => {
const count = days(value);

return count === null ? null : [now.minus({ days: count }).startOf('day'), now];
},
};

function within(name: keyof typeof WINDOWS) {
return (actual: unknown, expected: unknown, clock: Clock): boolean => {
const instant = toInstant(actual, clock.timezone);
if (instant === null) return false;

const window = WINDOWS[name](DateTime.fromJSDate(clock.now).setZone(clock.timezone), expected);
if (window === null) return false;

const [start, end] = window.map(bound => alignToValueKind(actual, bound));
const afterStart = DATE_ONLY.test(actual as string) ? instant >= start : instant > start;

return afterStart && instant < end;
};
}

function relativeTo(
bound: (now: DateTime, value: unknown) => DateTime | null,
satisfies: (actual: DateTime, bound: DateTime) => boolean,
) {
return (actual: unknown, expected: unknown, clock: Clock): boolean => {
const instant = toInstant(actual, clock.timezone);
if (instant === null) return false;

const reference = bound(DateTime.fromJSDate(clock.now).setZone(clock.timezone), expected);

return reference !== null && satisfies(instant, alignToValueKind(actual, reference));
};
}

function hoursAgo(now: DateTime, value: unknown): DateTime | null {
const count = toNumber(value);

return count !== null && count >= 0 ? now.minus({ hours: count }) : null;
}

const EVALUATORS: Record<
Exclude<ConditionOperator, 'present' | 'blank'>,
(actual: unknown, expected: unknown) => boolean
(actual: unknown, expected: unknown, clock: Clock) => 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) => memberOf(expected, actual) === true,
not_in: (actual, expected) => memberOf(expected, actual) === false,
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),
in: (actual, expected) => isMemberOf(expected, actual),
includes_all: includesAll,
contains: stringTest((actual, expected) => actual.includes(expected)),
not_contains: stringTest((actual, expected) => !actual.includes(expected)),
starts_with: stringTest((actual, expected) => actual.startsWith(expected)),
ends_with: stringTest((actual, expected) => actual.endsWith(expected)),
// Case folded, accents kept: the Postgres ILIKE path of the list filter ('É' ILIKE '%é%' holds,
// 'é' ILIKE '%e%' does not). MySQL and SQLite collations diverge; Postgres is the contract.
i_contains: stringTest((actual, expected) =>
actual.toLowerCase().includes(expected.toLowerCase()),
),
before: dateOrdering((actual, expected) => actual < expected),
after: dateOrdering((actual, expected) => actual > expected),
past: relativeTo(
now => now,
(actual, bound) => actual < bound,
),
future: relativeTo(
now => now,
(actual, bound) => actual > bound,
),
before_x_hours_ago: relativeTo(hoursAgo, (actual, bound) => actual < bound),
after_x_hours_ago: relativeTo(hoursAgo, (actual, bound) => actual > bound),
today: within('today'),
yesterday: within('yesterday'),
previous_x_days: within('previous_x_days'),
previous_x_days_to_date: within('previous_x_days_to_date'),
};

/**
Expand All @@ -167,10 +309,11 @@ export default function evaluateOperator(
operator: ConditionOperator,
actual: unknown,
expected: unknown,
clock: Clock,
): 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);
return EVALUATORS[operator](actual, expected, clock);
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export default class StepExecutorFactory {
stepDefinition: step.stepDefinition,
previousSteps: step.previousSteps,
user: step.user,
timezone: step.timezone,
model: cfg.aiModelPort.getModel({
aiConfigName: step.stepDefinition.aiConfigName,
userId: step.user.id,
Expand Down
1 change: 1 addition & 0 deletions packages/workflow-executor/src/types/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ExecutionContext<TStep extends StepDefinition = StepDefinition>
readonly activityLog: ActivityLog;
readonly runStore: RunStore;
readonly user: StepUser;
readonly timezone: string;
readonly schemaResolver: SchemaResolver;
readonly previousSteps: ReadonlyArray<Readonly<Step>>;
readonly logger: Logger;
Expand Down
3 changes: 3 additions & 0 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export interface DeterministicConditionExecutionParams {
evaluations: ConditionEvaluation[];
selectedOption: string;
usedFallback: boolean;
// Without the instant, a check on "previous 7 days" cannot be explained a day later.
evaluatedAt: string;
timezone: string;
}

export interface ConditionStepExecutionData extends BaseStepExecutionData {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const AvailableStepExecutionSchema = z
stepDefinition: StepDefinitionSchema,
previousSteps: z.array(StepSchema),
user: StepUserSchema,
timezone: z.string().min(1),

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 validated/execution.ts:53

Invalid timezone values such as "Fantasia/Castle" pass AvailableStepExecutionSchema.parse, causing Luxon to evaluate relative-date windows with an invalid clock zone and silently route Decisions to their fallback. Validate the value as an IANA zone here, or normalize invalid values to UTC before they reach the evaluator.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/types/validated/execution.ts around line 53:

Invalid timezone values such as `"Fantasia/Castle"` pass `AvailableStepExecutionSchema.parse`, causing Luxon to evaluate relative-date windows with an invalid clock zone and silently route Decisions to their fallback. Validate the value as an IANA zone here, or normalize invalid values to `UTC` before they reach the evaluator.

})
.strict();
export type AvailableStepExecution = z.infer<typeof AvailableStepExecutionSchema>;
Loading
Loading