diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 3c7d6e105b..9e45f0a7cc 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Overview -`@forestadmin/workflow-executor` — a framework-agnostic TypeScript library that runs Forest Admin workflow **steps on the client's infrastructure**, next to the Forest Admin agent. The orchestrator sends only step *definitions* (metadata, never client data); this package fetches them, executes locally with access to client data, and reports outcomes back. +`@forestadmin/workflow-executor` — a framework-agnostic TypeScript library that runs Forest Admin workflow **steps on the client's infrastructure**, next to the Forest Admin agent. The orchestrator sends only step _definitions_ (metadata, never client data); this package fetches them, executes locally with access to client data, and reports outcomes back. **Why it exists:** workflows historically ran entirely in the **frontend** (BPMN parsing, run state machine, AI calls, tool execution). That blocks automation (scheduled / API-triggered / headless runs need a browser open). This package moves step execution to the backend. It must stay **behavior-ISO with the front** (`forestadmin/frontend`, `app/features/workflow/`): same tool schemas, AI interactions, fallback logic. @@ -15,6 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ``` Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this pkg) ──▶ Agent (datasources) ``` + - **Front** — designs workflows, triggers runs, shows progress. Still executes the `awaiting-input` path (manual decisions, action forms). - **Orchestrator** (Forest server) — stores definitions, manages run state machines, dispatches steps. Never sees client data. - **Executor** (this pkg) — pulls pending steps, runs them locally, reports `StepOutcome`. **The privacy boundary lives here.** @@ -36,19 +37,21 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p `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`. **No condition evaluation ever fails the step, data or reference alike**: a resolved value that is null is `met: null`, and a reference that read nothing is `met: null` + a `reason` (`source-step-not-reached` when no Get Data with that id ran on this path, `field-not-loaded` when one ran but the field is absent or errored), plus a `Warn`. Build-time validation cannot cover the second case (a Get Data step may let the AI pick its fields), so the run has to answer at runtime — and the way a decision step answers is by routing, unlike record steps (line above) which die on an unresolvable reference. What stops the fallback from passing for a decision the data took is the `reason`: the run view words it for the operator instead of showing a bare cross. "Never read" is not "read and empty", so `present`/`blank` get no answer out of it either. 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. + - **Operator set = what a list view filter offers** (PRD-1147), 23 names in `CONDITION_OPERATORS`; nothing the front registry cannot render (so no `>=`, `<=`, `not_in`). 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`), whole numbers exactly as BigInt; 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); text operators are strings-only, case sensitive except `i_contains`, which folds case and keeps accents like the list filter's Postgres `ILIKE` path (other dialects' collations diverge; Postgres is the contract). + - **Relative dates read an injected `Clock`** (`{ now, timezone }`), never the machine's: the step executor builds one per step (so every condition of every option sees the same instant) from `ExecutionContext.timezone`, which the orchestrator sends as the **project's** zone (`AvailableStepExecution.timezone`, mapper falls back to `UTC` when unset or absent). The machine's zone is never used: the fleet runs several instances, and the same run must route the same on each. Day windows (`today`, `yesterday`, `previous_x_days`, `previous_x_days_to_date`) are computed in that zone with the exact bounds of `datasource-toolkit`'s time transforms: end excluded, start included for a calendar date and excluded for a datetime (the toolkit emits `GreaterThanOrEqual` for Dateonly, `GreaterThan` otherwise), and a mid-day bound (`past`, `before_x_hours_ago`, the end of `previous_x_days_to_date`) read at the start of its day for a calendar date (the toolkit formats the bound with `toISODate` for a Dateonly column), so a record answers "today" the same way in a Decision and in a list filter; a **calendar date** (Dateonly) is read at midnight in that zone, otherwise Honolulu's own "today" would count as yesterday. An unknown zone name falls back to `UTC` in the mapper, like an absent one. The trace persists `evaluatedAt` + `timezone` alongside `evaluations`, since a relative condition cannot be explained later without the instant it was read at. A retry re-evaluates with a fresh clock and may route differently: accepted, by product decision. + +- **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. All four record steps pin the source by `selectedRecordStepId` — a **stable BPMN step id** (or the `WORKFLOW_START_STEP_ID` sentinel) resolved by `resolveSourceRecordRef`, chosen to survive the index shifts a revision causes; the editor writes it and treats it as a precondition for choosing fields. Presence, not truthiness, decides whether something is pinned -- `selectedRecordStepId`, `actionName` and `relationName` all check `!== undefined`. An empty value is a pin that lost its target, so it resolves to nothing and errors instead of falling back to the AI, which would silently pick a different record, run a different action, or follow a different relation than the step was configured to. Not reachable from the editor, which writes `stepId || undefined`. `selectedRecordStepIndex` (a runtime index, resolved in `resolveRecordRef`) survives only as a fallback on read-record/update-record, checked after the step id. Partial args supported. Unresolvable → `PinnedArgNotFoundError` when the name was pinned (`configuration`), `FieldNotFoundError`/`ActionNotFoundError`/`RelationNotFoundError` when the AI chose it (**unclassified** — a re-run may resolve differently, so nothing permanent can be asserted); bad shape / out-of-range index → `InvalidPreRecordedArgsError`. The split exists because the AI-facing messages tell the operator to rephrase the prompt — the right remedy for a name the AI chose, and unreachable for one the workflow fixed, where the step itself is the thing to edit. One class covers every pinned kind: the diagnosis differs per kind, the operator's remedy does not. Anything raised on a pinned value has to name the step. ## Invariants (read before changing executors) - **Privacy** — `StepOutcome` goes to the orchestrator and must **never** contain client data. Privacy-sensitive info (AI reasoning, record values) stays in `StepExecutionData` (RunStore, client-side only). - **Error hierarchy** (`errors.ts`): - - *Step-execution errors* extend `WorkflowExecutorError` → caught by `base-step-executor.ts`, turned into `stepOutcome.error`. Never reach HTTP. - - *Boundary errors* (`ConfigurationError`, `PendingDataNotFoundError`, `AgentProbeError`, …) extend plain `Error` → caught at HTTP/Runner layer. They must **not** extend `WorkflowExecutorError` (or the base executor would swallow them). - - `WorkflowExecutorError` carries `message` (technical, logs) **and** `userMessage` (end-user, surfaced via `stepOutcome.error`). New subclasses must set a distinct, jargon-free `userMessage`. Request-level errors extend a *category* (`NotFoundError`/`AccessDeniedError`/`UnavailableError`) so `toHttpError` maps status by category — no per-error binding. - - **Error kind** — `errorKind` (`operator`/`configuration`/`system`) classifies what kind of failure a step error is. It does not encode ownership: the front maps `configuration`/`system` to admin-phrased copy as a reasonable default, which is a front-end choice, not a property of the enum. One abstract per classified kind declares it once (`WorkflowOperatorError`, `WorkflowConfigurationError`, each setting `static defaultErrorKind`); a new member joins a family by extending it, and an error extending neither stays unclassified. The throw site overrides only where the same error can be either kind (`SourceRecordMissingError`, on whether a candidate was offered). Unset ⇒ absent from the outcome ⇒ the front frames it as it always has, so leaving a new error unclassified is safe. `errorSourceStepIndex` names the step an error is *about*, by index rather than step id — a LinkTo loop repeats ids, so only the index identifies the iteration. + - _Step-execution errors_ extend `WorkflowExecutorError` → caught by `base-step-executor.ts`, turned into `stepOutcome.error`. Never reach HTTP. + - _Boundary errors_ (`ConfigurationError`, `PendingDataNotFoundError`, `AgentProbeError`, …) extend plain `Error` → caught at HTTP/Runner layer. They must **not** extend `WorkflowExecutorError` (or the base executor would swallow them). + - `WorkflowExecutorError` carries `message` (technical, logs) **and** `userMessage` (end-user, surfaced via `stepOutcome.error`). New subclasses must set a distinct, jargon-free `userMessage`. Request-level errors extend a _category_ (`NotFoundError`/`AccessDeniedError`/`UnavailableError`) so `toHttpError` maps status by category — no per-error binding. + - **Error kind** — `errorKind` (`operator`/`configuration`/`system`) classifies what kind of failure a step error is. It does not encode ownership: the front maps `configuration`/`system` to admin-phrased copy as a reasonable default, which is a front-end choice, not a property of the enum. One abstract per classified kind declares it once (`WorkflowOperatorError`, `WorkflowConfigurationError`, each setting `static defaultErrorKind`); a new member joins a family by extending it, and an error extending neither stays unclassified. The throw site overrides only where the same error can be either kind (`SourceRecordMissingError`, on whether a candidate was offered). Unset ⇒ absent from the outcome ⇒ the front frames it as it always has, so leaving a new error unclassified is safe. `errorSourceStepIndex` names the step an error is _about_, by index rather than step id — a LinkTo loop repeats ids, so only the index identifies the iteration. - Both fields ride `context` on the update-step request and are read back by `run-to-available-step-mapper`, which drops an off-vocabulary value instead of passing it on: it would fail `AvailableStepExecutionSchema.parse` and take the whole run down. The front equality-matches `operator`, so a widened enum degrades an older front rather than breaking it. - The orchestrator merges step `context` shallowly and an unclassified error omits `errorKind` rather than nulling it, so a kind written on a step index cannot be cleared server-side. That is safe only because no index ever receives two error reports: an error sets `done: true`, a done step is never re-dispatched, and every start appends a fresh index with an empty context. If a retryable error is ever left pending instead of done, this stops holding. - `errorKind` is unrelated to ai-proxy's `McpLoadFailureKind` (`auth`/`connection`/`unknown`, reported per server on the MCP `failures` channel): that one says where a tool load broke, this one says what kind of failure the step hit. They are deliberately separate vocabularies — don't map one onto the other. @@ -63,7 +66,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p - **Graceful shutdown** — `stop()` drains in-flight steps (`idle → running → draining → stopped`), `stopTimeoutMs` default 30s, HTTP stays up during drain. Signal handling is the consumer's job. Trigger-started chains fall under the same drain as polled ones: a deploy mid-chain truncates them at `stopTimeoutS` (they used to be held open by the unanswered request). - **Logging** — `Logger = (level, message, context?) => void`. `BaseStepExecutor` stamps `logCtx` (runId/stepId/stepIndex/stepType); type-specific ids via `getExtraLogContext()`. `createConsoleLogger`/`createPrettyLogger(minLevel)` factories; CLI level from `LOG_LEVEL` (default `Info`). ai-proxy's logger takes the cause as a third `Error` argument instead of a context object, so both AI adapters bridge it with `toAiProxyLogger` (flattens to `{ error, cause, stack }` — an `Error`'s own properties are non-enumerable and would vanish from the emitted line — and swallows a throwing host logger, which ai-proxy calls from inside its catch blocks). - **MCP load failures come from the `failures` channel** — `RemoteToolFetcher` loads through `loadRemoteToolsWithFailures` and reports what the providers classified (`server`/`kind`/`error`); never infer failure from absent tools, which flags a healthy server exposing none. `loadFailed` drives the 503 on `GET /list-mcp-tools`, so a wrong inference is user-visible. -- **Config comes from the boundary, never `process.env`** — no executor *config* is read from `process.env` outside `cli-core`: every knob is parsed there (standalone) or injected as an option (`ExecutorOptions` / the agent's `addWorkflowExecutor` options), and the check for a value is `Boolean(options.x)`, not `process.env`. Runtime-mode flags — `NODE_ENV` (forceAiError prod-guard, token-endpoint dev check) and the `OTEL_*` observability vars in `tracing.ts` — are the deliberate exception. This keeps the executor identically configurable standalone and embedded, and testable without mutating env. (Regression fixed once: `FOREST_EXECUTOR_ENCRYPTION_KEY` was read in `crypto/` — now injected via `executorEncryptionKey`.) +- **Config comes from the boundary, never `process.env`** — no executor _config_ is read from `process.env` outside `cli-core`: every knob is parsed there (standalone) or injected as an option (`ExecutorOptions` / the agent's `addWorkflowExecutor` options), and the check for a value is `Boolean(options.x)`, not `process.env`. Runtime-mode flags — `NODE_ENV` (forceAiError prod-guard, token-endpoint dev check) and the `OTEL_*` observability vars in `tracing.ts` — are the deliberate exception. This keeps the executor identically configurable standalone and embedded, and testable without mutating env. (Regression fixed once: `FOREST_EXECUTOR_ENCRYPTION_KEY` was read in `crypto/` — now injected via `executorEncryptionKey`.) - **AI** — import every AI type (`BaseChatModel`, `DynamicStructuredTool`, `SystemMessage`/`HumanMessage`, `RemoteTool`/`ToolConfig`) from `@forestadmin/ai-proxy`, **not** `@langchain/core` (which is not a dependency). `ExecutionContext.model` is a `BaseChatModel`. The only langchain mention in src is a comment in `cli.ts` about transitively loading `@langchain/openai`. ## Commands diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index 1972cbca43..a5b4d840fa 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -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", @@ -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" diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index 942bd28899..40918aa6d5 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -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'; @@ -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, diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 831479a87b..6d9c262e1d 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -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) --- diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 298d142a7b..ff191253c5 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -16,7 +16,7 @@ import { z } from 'zod'; 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, @@ -127,6 +127,8 @@ export default class ConditionStepExecutor extends BaseStepExecutor { 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 }) => { @@ -135,7 +137,7 @@ export default class ConditionStepExecutor extends BaseStepExecutor { - const { met, reason } = this.evaluateCondition(condition, stepExecutions); + const { met, reason } = this.evaluateCondition(condition, stepExecutions, clock); return { index, met, ...(reason && { reason }) }; }); @@ -167,7 +169,13 @@ export default class ConditionStepExecutor extends BaseStepExecutor 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) { @@ -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, - (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'), }; /** @@ -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); } diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 65e09ca151..30bd70cc0f 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -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, diff --git a/packages/workflow-executor/src/types/execution-context.ts b/packages/workflow-executor/src/types/execution-context.ts index 70d965a09f..77e800f173 100644 --- a/packages/workflow-executor/src/types/execution-context.ts +++ b/packages/workflow-executor/src/types/execution-context.ts @@ -33,6 +33,7 @@ export interface ExecutionContext readonly activityLog: ActivityLog; readonly runStore: RunStore; readonly user: StepUser; + readonly timezone: string; readonly schemaResolver: SchemaResolver; readonly previousSteps: ReadonlyArray>; readonly logger: Logger; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 15792ead6f..57c30db6d9 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -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 { diff --git a/packages/workflow-executor/src/types/validated/execution.ts b/packages/workflow-executor/src/types/validated/execution.ts index 1b80e70963..872be57761 100644 --- a/packages/workflow-executor/src/types/validated/execution.ts +++ b/packages/workflow-executor/src/types/validated/execution.ts @@ -49,6 +49,7 @@ export const AvailableStepExecutionSchema = z stepDefinition: StepDefinitionSchema, previousSteps: z.array(StepSchema), user: StepUserSchema, + timezone: z.string().min(1), }) .strict(); export type AvailableStepExecution = z.infer; diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 0a3f2cc284..2e56665825 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -42,6 +42,8 @@ const { Manual, AutomatedWithConfirmation, FullyAutomated } = StepExecutionMode; // Wire-final operator names (PRD-472 cross-repo contract). An unknown operator is rejected here, // at the schema boundary, so a run never reaches evaluation with a comparison it cannot honor. +// The set a list view filter offers, no more: every name here has a Filters operator in the front +// (which is what the builder can render) and a translation in the orchestrator's parser. export const CONDITION_OPERATORS = [ 'equal', 'not_equal', @@ -49,16 +51,34 @@ export const CONDITION_OPERATORS = [ 'blank', 'greater_than', 'less_than', - 'greater_than_or_equal', - 'less_than_or_equal', 'in', - 'not_in', + 'includes_all', 'contains', 'not_contains', + 'starts_with', + 'ends_with', + 'i_contains', + 'before', + 'after', + 'past', + 'future', + 'today', + 'yesterday', + 'previous_x_days', + 'previous_x_days_to_date', + 'before_x_hours_ago', + 'after_x_hours_ago', ] as const; export type ConditionOperator = (typeof CONDITION_OPERATORS)[number]; -const VALUE_LESS_OPERATORS: readonly ConditionOperator[] = ['present', 'blank']; +const VALUE_LESS_OPERATORS: readonly ConditionOperator[] = [ + 'present', + 'blank', + 'past', + 'future', + 'today', + 'yesterday', +]; const DeterministicConditionSchema = z .object({ @@ -66,7 +86,6 @@ const DeterministicConditionSchema = z sourceStepId: z.string().min(1), fieldName: z.string().min(1), operator: z.enum(CONDITION_OPERATORS), - /** Absent for `present`/`blank`. */ value: z.unknown().optional(), }) // A value-bearing operator without its value compares against `undefined`: it can never be met, diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 12b3127618..c8b9bfff45 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -126,9 +126,25 @@ describe('toAvailableStepExecution', () => { }, previousSteps: [], user: expect.objectContaining({ id: 7, email: 'alban@forestadmin.com' }), + timezone: 'UTC', }); }); + it('should forward the project timezone', () => { + const result = toAvailableStepExecution(makeRun({ timezone: 'Europe/Paris' })); + + expect(result?.timezone).toBe('Europe/Paris'); + }); + + it.each([null, undefined, '', 'Mars/Olympus'])( + 'should fall back to UTC when the timezone is %p', + timezone => { + const result = toAvailableStepExecution(makeRun({ timezone })); + + expect(result?.timezone).toBe('UTC'); + }, + ); + it('should forward the run triggerType', () => { const run = makeRun({ triggerType: ServerWorkflowTriggerType.webhook }); diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index 1d4db6c2f5..dad5393a96 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -168,6 +168,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: makeMockLogger(), ...overrides, }; 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 10753c3bf0..5ed4f3ffe4 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -122,6 +122,7 @@ function makeContext( permissionLevel: 'admin', tags: {}, }, + timezone: 'UTC', schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], logger: jest.fn(), @@ -476,8 +477,8 @@ describe('ConditionStepExecutor', () => { { sourceStepId: 'get-1', fieldName: 'amount', - operator: 'less_than_or_equal', - value: 100, + operator: 'less_than', + value: 101, }, ], }, @@ -576,6 +577,8 @@ describe('ConditionStepExecutor', () => { ], selectedOption: 'High', usedFallback: false, + evaluatedAt: expect.any(String), + timezone: 'UTC', }, executionResult: { answer: 'High' }, }); @@ -655,6 +658,8 @@ describe('ConditionStepExecutor', () => { ], selectedOption: 'Other', usedFallback: true, + evaluatedAt: expect.any(String), + timezone: 'UTC', }, executionResult: { answer: 'Other' }, }); @@ -724,6 +729,8 @@ describe('ConditionStepExecutor', () => { ], selectedOption: 'Other', usedFallback: true, + evaluatedAt: expect.any(String), + timezone: 'UTC', }, executionResult: { answer: 'Other' }, }, @@ -774,6 +781,8 @@ describe('ConditionStepExecutor', () => { ], selectedOption: 'Other', usedFallback: true, + evaluatedAt: expect.any(String), + timezone: 'UTC', }, executionResult: { answer: 'Other' }, }); @@ -947,6 +956,8 @@ describe('ConditionStepExecutor', () => { ], selectedOption: 'Other', usedFallback: true, + evaluatedAt: expect.any(String), + timezone: 'UTC', }, executionResult: { answer: 'Other' }, }); @@ -1127,6 +1138,70 @@ describe('ConditionStepExecutor', () => { ); }); + // 2026-09-04T23:00Z is still 4 September in UTC but already 5 September in Paris. A record + // stamped 2026-09-05T00:30Z is "today" in Paris and tomorrow in UTC, so the option is taken + // only if the project zone is what the evaluator was handed. + it('reads relative dates in the context timezone and records the instant it used', async () => { + const todayInParis: ConditionPreRecordedArgs = { + optionConditions: [ + { + option: 'Today', + aggregator: 'and', + conditions: [{ sourceStepId: 'get-1', fieldName: 'signedAt', operator: 'today' }], + }, + ], + fallbackOption: 'Other', + }; + const { context, runStore } = makeDeterministicContext( + todayInParis, + [{ name: 'signedAt', displayName: 'Signed at', value: '2026-09-05T00:30:00Z' }], + { timezone: 'Europe/Paris' }, + ); + + try { + jest.useFakeTimers().setSystemTime(new Date('2026-09-04T23:00:00Z')); + const result = await new ConditionStepExecutor(context).execute(); + + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Today'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionParams: expect.objectContaining({ + evaluatedAt: '2026-09-04T23:00:00.000Z', + timezone: 'Europe/Paris', + }), + }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('does not read a record from the next UTC day as today when the project zone is UTC', async () => { + const todayArgs: ConditionPreRecordedArgs = { + optionConditions: [ + { + option: 'Today', + aggregator: 'and', + conditions: [{ sourceStepId: 'get-1', fieldName: 'signedAt', operator: 'today' }], + }, + ], + fallbackOption: 'Other', + }; + const { context } = makeDeterministicContext(todayArgs, [ + { name: 'signedAt', displayName: 'Signed at', value: '2026-09-05T00:30:00Z' }, + ]); + + try { + jest.useFakeTimers().setSystemTime(new Date('2026-09-04T23:00:00Z')); + const result = await new ConditionStepExecutor(context).execute(); + + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Other'); + } finally { + jest.useRealTimers(); + } + }); + it('uses the most recent occurrence of a repeated source step id (loop)', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest diff --git a/packages/workflow-executor/test/executors/deterministic-condition-evaluator.test.ts b/packages/workflow-executor/test/executors/deterministic-condition-evaluator.test.ts index 1126907c04..d28c3b3d80 100644 --- a/packages/workflow-executor/test/executors/deterministic-condition-evaluator.test.ts +++ b/packages/workflow-executor/test/executors/deterministic-condition-evaluator.test.ts @@ -1,5 +1,16 @@ +import type { Clock } from '../../src/executors/deterministic-condition-evaluator'; +import type { ConditionOperator } from '../../src/types/validated/step-definition'; + import evaluateOperator from '../../src/executors/deterministic-condition-evaluator'; +// A fixed instant in a non-UTC zone: 2026-09-04 12:30 in Paris (UTC+2 in September), so any +// operator that silently read the machine's clock or UTC's day would give itself away. +const CLOCK: Clock = { now: new Date('2026-09-04T10:30:00Z'), timezone: 'Europe/Paris' }; + +function ev(operator: ConditionOperator, actual: unknown, expected?: unknown, clock = CLOCK) { + return evaluateOperator(operator, actual, expected, clock); +} + describe('evaluateOperator', () => { describe('null / missing actual value (never an error)', () => { it.each([ @@ -7,188 +18,181 @@ describe('evaluateOperator', () => { 'not_equal', 'greater_than', 'less_than', - 'greater_than_or_equal', - 'less_than_or_equal', 'in', - 'not_in', + 'includes_all', 'contains', 'not_contains', + 'starts_with', + 'ends_with', + 'i_contains', + 'before', + 'after', + 'past', + 'future', + 'today', + 'yesterday', + 'previous_x_days', + 'previous_x_days_to_date', + 'before_x_hours_ago', + 'after_x_hours_ago', ] as const)('returns null (not evaluable) for %s on a null actual', operator => { - expect(evaluateOperator(operator, null, 'anything')).toBeNull(); - expect(evaluateOperator(operator, undefined, 'anything')).toBeNull(); + expect(ev(operator, null, 'anything')).toBeNull(); + expect(ev(operator, undefined, 'anything')).toBeNull(); }); }); describe('equal', () => { it('matches identical scalars', () => { - expect(evaluateOperator('equal', 'active', 'active')).toBe(true); - expect(evaluateOperator('equal', 5, 5)).toBe(true); - expect(evaluateOperator('equal', false, false)).toBe(true); + expect(ev('equal', 'active', 'active')).toBe(true); + expect(ev('equal', 5, 5)).toBe(true); + expect(ev('equal', false, false)).toBe(true); }); it('rejects different scalars', () => { - expect(evaluateOperator('equal', 'active', 'inactive')).toBe(false); + expect(ev('equal', 'active', 'inactive')).toBe(false); }); it('rejects a type mismatch', () => { - expect(evaluateOperator('equal', true, 'true')).toBe(false); - expect(evaluateOperator('equal', 'abc', 100)).toBe(false); + expect(ev('equal', true, 'true')).toBe(false); + expect(ev('equal', 'abc', 100)).toBe(false); }); it('matches ISO dates by timestamp, not by string', () => { - expect(evaluateOperator('equal', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00.000Z')).toBe( - true, - ); - expect(evaluateOperator('equal', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')).toBe(false); + expect(ev('equal', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00.000Z')).toBe(true); + expect(ev('equal', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z')).toBe(false); }); it('matches arrays elementwise in order', () => { - expect(evaluateOperator('equal', [1, 2], [1, 2])).toBe(true); - expect(evaluateOperator('equal', [1, 2], [2, 1])).toBe(false); - expect(evaluateOperator('equal', [1, 2], [1, 2, 3])).toBe(false); + expect(ev('equal', [1, 2], [1, 2])).toBe(true); + expect(ev('equal', [1, 2], [2, 1])).toBe(false); + expect(ev('equal', [1, 2], [1, 2, 3])).toBe(false); }); it('rejects an array compared to a scalar', () => { - expect(evaluateOperator('equal', [1], 1)).toBe(false); + expect(ev('equal', [1], 1)).toBe(false); }); }); describe('not_equal', () => { it('matches different values', () => { - expect(evaluateOperator('not_equal', 'active', 'inactive')).toBe(true); - expect(evaluateOperator('not_equal', 5, 6)).toBe(true); + expect(ev('not_equal', 'active', 'inactive')).toBe(true); + expect(ev('not_equal', 5, 6)).toBe(true); }); it('rejects identical values', () => { - expect(evaluateOperator('not_equal', 'active', 'active')).toBe(false); - expect( - evaluateOperator('not_equal', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00.000Z'), - ).toBe(false); + expect(ev('not_equal', 'active', 'active')).toBe(false); + expect(ev('not_equal', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00.000Z')).toBe(false); }); it('is not satisfied by a type mismatch, like every other operator', () => { - expect(evaluateOperator('not_equal', true, 'true')).toBe(false); - expect(evaluateOperator('not_equal', 5, 'abc')).toBe(false); - expect(evaluateOperator('not_equal', ['a'], 'a')).toBe(false); + expect(ev('not_equal', true, 'true')).toBe(false); + expect(ev('not_equal', 5, 'abc')).toBe(false); + expect(ev('not_equal', ['a'], 'a')).toBe(false); }); }); describe('numeric strings (decimal/bigint columns come back as strings)', () => { it('compares a numeric string against a number', () => { - expect(evaluateOperator('greater_than', '150.00', 100)).toBe(true); - expect(evaluateOperator('greater_than', '50.00', 100)).toBe(false); - expect(evaluateOperator('less_than', 100, '150.00')).toBe(true); - expect(evaluateOperator('greater_than_or_equal', '100', 100)).toBe(true); - expect(evaluateOperator('less_than_or_equal', '-3', 0)).toBe(true); + expect(ev('greater_than', '150.00', 100)).toBe(true); + expect(ev('greater_than', '50.00', 100)).toBe(false); + expect(ev('less_than', 100, '150.00')).toBe(true); + expect(ev('greater_than', '100', 99)).toBe(true); + expect(ev('less_than', '-3', 0)).toBe(true); }); it('equates a numeric string with a number', () => { - expect(evaluateOperator('equal', '42', 42)).toBe(true); - expect(evaluateOperator('equal', 42, '42.0')).toBe(true); - expect(evaluateOperator('not_equal', '42', 42)).toBe(false); - expect(evaluateOperator('in', '150.00', [100, 150])).toBe(true); + expect(ev('equal', '42', 42)).toBe(true); + expect(ev('equal', 42, '42.0')).toBe(true); + expect(ev('not_equal', '42', 42)).toBe(false); + expect(ev('in', '150.00', [100, 150])).toBe(true); }); it('leaves a non-numeric string uncoerced', () => { - expect(evaluateOperator('greater_than', 'abc', 100)).toBe(false); - expect(evaluateOperator('greater_than', '12abc', 100)).toBe(false); - expect(evaluateOperator('equal', '', 0)).toBe(false); + expect(ev('greater_than', 'abc', 100)).toBe(false); + expect(ev('greater_than', '12abc', 100)).toBe(false); + expect(ev('equal', '', 0)).toBe(false); }); it('does not coerce when neither side is a number', () => { - expect(evaluateOperator('greater_than', '5', '3')).toBe(false); + expect(ev('greater_than', '5', '3')).toBe(false); }); }); describe('present', () => { it('matches non-empty values', () => { - expect(evaluateOperator('present', 'a', undefined)).toBe(true); - expect(evaluateOperator('present', 0, undefined)).toBe(true); - expect(evaluateOperator('present', false, undefined)).toBe(true); - expect(evaluateOperator('present', [1], undefined)).toBe(true); + expect(ev('present', 'a', undefined)).toBe(true); + expect(ev('present', 0, undefined)).toBe(true); + expect(ev('present', false, undefined)).toBe(true); + expect(ev('present', [1], undefined)).toBe(true); }); it('rejects null, undefined, empty string and empty array', () => { - expect(evaluateOperator('present', null, undefined)).toBe(false); - expect(evaluateOperator('present', undefined, undefined)).toBe(false); - expect(evaluateOperator('present', '', undefined)).toBe(false); - expect(evaluateOperator('present', [], undefined)).toBe(false); + expect(ev('present', null, undefined)).toBe(false); + expect(ev('present', undefined, undefined)).toBe(false); + expect(ev('present', '', undefined)).toBe(false); + expect(ev('present', [], undefined)).toBe(false); }); }); describe('blank', () => { it('matches null, undefined, empty string and empty array', () => { - expect(evaluateOperator('blank', null, undefined)).toBe(true); - expect(evaluateOperator('blank', undefined, undefined)).toBe(true); - expect(evaluateOperator('blank', '', undefined)).toBe(true); - expect(evaluateOperator('blank', [], undefined)).toBe(true); + expect(ev('blank', null, undefined)).toBe(true); + expect(ev('blank', undefined, undefined)).toBe(true); + expect(ev('blank', '', undefined)).toBe(true); + expect(ev('blank', [], undefined)).toBe(true); }); it('rejects non-empty values including falsy ones', () => { - expect(evaluateOperator('blank', 'a', undefined)).toBe(false); - expect(evaluateOperator('blank', 0, undefined)).toBe(false); - expect(evaluateOperator('blank', false, undefined)).toBe(false); + expect(ev('blank', 'a', undefined)).toBe(false); + expect(ev('blank', 0, undefined)).toBe(false); + expect(ev('blank', false, undefined)).toBe(false); }); }); describe('numeric comparisons', () => { it('greater_than compares numbers', () => { - expect(evaluateOperator('greater_than', 5, 3)).toBe(true); - expect(evaluateOperator('greater_than', 3, 5)).toBe(false); - expect(evaluateOperator('greater_than', 5, 5)).toBe(false); + expect(ev('greater_than', 5, 3)).toBe(true); + expect(ev('greater_than', 3, 5)).toBe(false); + expect(ev('greater_than', 5, 5)).toBe(false); }); it('less_than compares numbers', () => { - expect(evaluateOperator('less_than', 3, 5)).toBe(true); - expect(evaluateOperator('less_than', 5, 3)).toBe(false); - expect(evaluateOperator('less_than', 5, 5)).toBe(false); - }); - - it('greater_than_or_equal includes equality', () => { - expect(evaluateOperator('greater_than_or_equal', 5, 5)).toBe(true); - expect(evaluateOperator('greater_than_or_equal', 4, 5)).toBe(false); - }); - - it('less_than_or_equal includes equality', () => { - expect(evaluateOperator('less_than_or_equal', 5, 5)).toBe(true); - expect(evaluateOperator('less_than_or_equal', 6, 5)).toBe(false); + expect(ev('less_than', 3, 5)).toBe(true); + expect(ev('less_than', 5, 3)).toBe(false); + expect(ev('less_than', 5, 5)).toBe(false); }); it('is not met on a type mismatch or non-comparable operands', () => { - expect(evaluateOperator('greater_than', 'abc', 'abd')).toBe(false); - expect(evaluateOperator('greater_than', true, 3)).toBe(false); - expect(evaluateOperator('less_than', Number.NaN, 5)).toBe(false); + expect(ev('greater_than', 'abc', 'abd')).toBe(false); + expect(ev('greater_than', true, 3)).toBe(false); + expect(ev('less_than', Number.NaN, 5)).toBe(false); }); }); describe('date comparisons', () => { it('compares ISO strings as timestamps when both sides parse', () => { - expect(evaluateOperator('greater_than', '2026-02-01', '2026-01-01')).toBe(true); - expect(evaluateOperator('less_than', '2026-01-01T10:00:00Z', '2026-01-01T12:00:00Z')).toBe( - true, - ); - expect(evaluateOperator('greater_than_or_equal', '2026-01-01T00:00:00Z', '2026-01-01')).toBe( - true, - ); - expect(evaluateOperator('less_than_or_equal', '2026-01-02', '2026-01-01')).toBe(false); + expect(ev('greater_than', '2026-02-01', '2026-01-01')).toBe(true); + expect(ev('less_than', '2026-01-01T10:00:00Z', '2026-01-01T12:00:00Z')).toBe(true); + expect(ev('greater_than', '2026-01-02T00:00:00Z', '2026-01-01')).toBe(true); + expect(ev('less_than', '2026-01-02', '2026-01-01')).toBe(false); }); it('is not met when one side does not parse as an ISO date', () => { - expect(evaluateOperator('greater_than', '2026-02-01', 'not a date')).toBe(false); - expect(evaluateOperator('less_than', 'not a date', '2026-02-01')).toBe(false); + expect(ev('greater_than', '2026-02-01', 'not a date')).toBe(false); + expect(ev('less_than', 'not a date', '2026-02-01')).toBe(false); }); it('treats an impossible calendar date as not a date instead of rolling it over', () => { - expect(evaluateOperator('equal', '2026-03-02', '2026-02-30')).toBe(false); - expect(evaluateOperator('equal', '2026-05-01', '2026-04-31')).toBe(false); - expect(evaluateOperator('equal', '2025-03-01', '2025-02-29')).toBe(false); - expect(evaluateOperator('greater_than', '2026-02-30', '2026-01-01')).toBe(false); - expect(evaluateOperator('less_than_or_equal', '2026-01-01', '2026-02-30')).toBe(false); - expect(evaluateOperator('in', '2026-03-02', ['2026-02-30'])).toBe(false); + expect(ev('equal', '2026-03-02', '2026-02-30')).toBe(false); + expect(ev('equal', '2026-05-01', '2026-04-31')).toBe(false); + expect(ev('equal', '2025-03-01', '2025-02-29')).toBe(false); + expect(ev('greater_than', '2026-02-30', '2026-01-01')).toBe(false); + expect(ev('less_than', '2026-01-01', '2026-02-30')).toBe(false); + expect(ev('in', '2026-03-02', ['2026-02-30'])).toBe(false); }); it('still accepts a leap day that exists', () => { - expect(evaluateOperator('equal', '2024-02-29', '2024-02-29T00:00:00.000Z')).toBe(true); + expect(ev('equal', '2024-02-29', '2024-02-29T00:00:00.000Z')).toBe(true); }); describe('on a host whose timezone is not UTC', () => { @@ -203,13 +207,18 @@ describe('evaluateOperator', () => { }); it('reads a datetime without an offset as UTC, not as host-local time', () => { - expect(evaluateOperator('equal', '2026-01-01T10:00:00', '2026-01-01T10:00:00Z')).toBe(true); - expect( - evaluateOperator('greater_than', '2026-01-01T12:00:00', '2026-01-01T11:00:00Z'), - ).toBe(true); - expect( - evaluateOperator('less_than', '2026-01-01T10:00:00', '2026-01-01T11:00:00+00:00'), - ).toBe(true); + expect(ev('equal', '2026-01-01T10:00:00', '2026-01-01T10:00:00Z')).toBe(true); + expect(ev('greater_than', '2026-01-01T12:00:00', '2026-01-01T11:00:00Z')).toBe(true); + expect(ev('less_than', '2026-01-01T10:00:00', '2026-01-01T11:00:00+00:00')).toBe(true); + }); + + // Date.parse falls back to a host-local legacy parser on the SQL form, which only a non-UTC + // host tells apart from the pinning. + it('reads the SQL datetime forms Postgres emits', () => { + expect(ev('equal', '2026-09-04 08:00:00', '2026-09-04T08:00:00Z')).toBe(true); + expect(ev('equal', '2026-09-04 08:00:00+02', '2026-09-04T06:00:00Z')).toBe(true); + expect(ev('equal', '2026-09-04 08:00:00 +02:00', '2026-09-04T06:00:00Z')).toBe(true); + expect(ev('equal', '2026-09-04 08:00:00.123', '2026-09-04T08:00:00.123Z')).toBe(true); }); }); }); @@ -222,106 +231,278 @@ describe('evaluateOperator', () => { const twoPow53 = 9007199254740992; it('does not read a bigint string as equal to the number it rounds to', () => { - expect(evaluateOperator('equal', justAbove, twoPow53)).toBe(false); - expect(evaluateOperator('not_equal', justAbove, twoPow53)).toBe(true); + expect(ev('equal', justAbove, twoPow53)).toBe(false); + expect(ev('not_equal', justAbove, twoPow53)).toBe(true); }); it('orders a bigint string against a threshold it exceeds by one', () => { - expect(evaluateOperator('greater_than', justAbove, twoPow53)).toBe(true); - expect(evaluateOperator('less_than', justAbove, twoPow53)).toBe(false); - expect(evaluateOperator('greater_than_or_equal', justAbove, twoPow53)).toBe(true); + expect(ev('greater_than', justAbove, twoPow53)).toBe(true); + expect(ev('less_than', justAbove, twoPow53)).toBe(false); }); it('excludes it from a list it rounds into', () => { - expect(evaluateOperator('in', justAbove, [twoPow53])).toBe(false); - expect(evaluateOperator('not_in', justAbove, [twoPow53])).toBe(true); + expect(ev('in', justAbove, [twoPow53])).toBe(false); }); // Decimals have no BigInt to be read as, so they keep the Number path. it('leaves decimals on the number path', () => { - expect(evaluateOperator('equal', '150.00', 150)).toBe(true); - expect(evaluateOperator('greater_than', '150.50', 150)).toBe(true); - expect(evaluateOperator('equal', 1.5, 1.5)).toBe(true); + expect(ev('equal', '150.00', 150)).toBe(true); + expect(ev('greater_than', '150.50', 150)).toBe(true); + expect(ev('equal', 1.5, 1.5)).toBe(true); }); it('still compares ordinary whole numbers', () => { - expect(evaluateOperator('equal', '150', 150)).toBe(true); - expect(evaluateOperator('greater_than', 150, 100)).toBe(true); - expect(evaluateOperator('less_than', '-20', 0)).toBe(true); + expect(ev('equal', '150', 150)).toBe(true); + expect(ev('greater_than', 150, 100)).toBe(true); + expect(ev('less_than', '-20', 0)).toBe(true); }); }); describe('in', () => { it('matches when the value is in the list', () => { - expect(evaluateOperator('in', 'b', ['a', 'b'])).toBe(true); - expect(evaluateOperator('in', 2, [1, 2, 3])).toBe(true); - expect(evaluateOperator('in', '2026-01-01T00:00:00Z', ['2026-01-01T00:00:00.000Z'])).toBe( - true, - ); + expect(ev('in', 'b', ['a', 'b'])).toBe(true); + expect(ev('in', 2, [1, 2, 3])).toBe(true); + expect(ev('in', '2026-01-01T00:00:00Z', ['2026-01-01T00:00:00.000Z'])).toBe(true); }); it('rejects when the value is not in the list', () => { - expect(evaluateOperator('in', 'c', ['a', 'b'])).toBe(false); - expect(evaluateOperator('in', 2, ['3'])).toBe(false); + expect(ev('in', 'c', ['a', 'b'])).toBe(false); + expect(ev('in', 2, ['3'])).toBe(false); }); it('is not met when the expected value is not an array', () => { - expect(evaluateOperator('in', 'a', 'a')).toBe(false); + expect(ev('in', 'a', 'a')).toBe(false); }); it('is not met when no member of the list is comparable to the value', () => { - expect(evaluateOperator('in', true, ['true'])).toBe(false); + expect(ev('in', true, ['true'])).toBe(false); }); }); - describe('not_in', () => { - it('matches when the value is absent from the list', () => { - expect(evaluateOperator('not_in', 'c', ['a', 'b'])).toBe(true); + describe('contains', () => { + it('matches a substring on strings', () => { + expect(ev('contains', 'hello world', 'world')).toBe(true); + expect(ev('contains', 'hello', 'world')).toBe(false); }); - it('rejects when the value is in the list', () => { - expect(evaluateOperator('not_in', 'a', ['a', 'b'])).toBe(false); + it('is not met on anything but two strings (contract: String fields only)', () => { + expect(ev('contains', ['a', 'b'], 'b')).toBe(false); + expect(ev('contains', 5, '5')).toBe(false); + expect(ev('contains', 'abc', 5)).toBe(false); }); + }); - it('is not met (never satisfied by mismatch) when the expected value is not an array', () => { - expect(evaluateOperator('not_in', 'a', 'b')).toBe(false); + describe('not_contains', () => { + it('matches when the substring is absent', () => { + expect(ev('not_contains', 'hello', 'world')).toBe(true); + expect(ev('not_contains', 'hello world', 'world')).toBe(false); }); - // The negated operators are the ones a type mismatch could accidentally satisfy: nothing in the - // list is comparable to the value, so "absent from the list" is a claim we cannot make. - it('is not met when no member of the list is comparable to the value', () => { - expect(evaluateOperator('not_in', true, ['true'])).toBe(false); - expect(evaluateOperator('not_in', 'closed', ['active', 5])).toBe(false); + it('is not met (never satisfied by mismatch) on anything but two strings', () => { + expect(ev('not_contains', ['a'], 'b')).toBe(false); + expect(ev('not_contains', 5, '5')).toBe(false); + expect(ev('not_contains', 'abc', 5)).toBe(false); + }); + }); + + describe('string operators', () => { + it('starts_with and ends_with are case sensitive', () => { + expect(ev('starts_with', 'active', 'act')).toBe(true); + expect(ev('starts_with', 'active', 'Act')).toBe(false); + expect(ev('ends_with', 'active', 'ive')).toBe(true); + expect(ev('ends_with', 'active', 'IVE')).toBe(false); + }); + + // What Postgres ILIKE does, measured on an en_US.utf8 database: 'É' ILIKE '%é%' holds, + // 'é' ILIKE '%e%' does not. Case is folded, accents are letters of their own. + it('i_contains folds case but keeps accents, like Postgres ILIKE', () => { + expect(ev('i_contains', 'ACTIVE', 'act')).toBe(true); + expect(ev('i_contains', 'active', 'ACT')).toBe(true); + expect(ev('i_contains', 'É', 'é')).toBe(true); + expect(ev('i_contains', 'é', 'e')).toBe(false); }); - it('still matches against an empty list, which mismatches nothing', () => { - expect(evaluateOperator('not_in', 'c', [])).toBe(true); + it('never coerces a non-string into text', () => { + expect(ev('starts_with', 150, '15')).toBe(false); + expect(ev('i_contains', 'abc', 1)).toBe(false); }); }); - describe('contains', () => { - it('matches a substring on strings', () => { - expect(evaluateOperator('contains', 'hello world', 'world')).toBe(true); - expect(evaluateOperator('contains', 'hello', 'world')).toBe(false); + describe('includes_all', () => { + it('matches when every wanted value is a member', () => { + expect(ev('includes_all', ['a', 'b', 'c'], ['a', 'c'])).toBe(true); + expect(ev('includes_all', ['a', 'b'], 'a')).toBe(true); }); - it('is not met on anything but two strings (contract: String fields only)', () => { - expect(evaluateOperator('contains', ['a', 'b'], 'b')).toBe(false); - expect(evaluateOperator('contains', 5, '5')).toBe(false); - expect(evaluateOperator('contains', 'abc', 5)).toBe(false); + it('rejects when one wanted value is missing, or the actual is not a list', () => { + expect(ev('includes_all', ['a'], ['a', 'b'])).toBe(false); + expect(ev('includes_all', 'a', ['a'])).toBe(false); + }); + + // every() on an empty list is vacuously true, which would make a blank widget match every + // record instead of none. + it('is not met on an empty wanted list', () => { + expect(ev('includes_all', ['a'], [])).toBe(false); }); }); - describe('not_contains', () => { - it('matches when the substring is absent', () => { - expect(evaluateOperator('not_contains', 'hello', 'world')).toBe(true); - expect(evaluateOperator('not_contains', 'hello world', 'world')).toBe(false); + describe('before / after', () => { + it('orders two instants strictly', () => { + expect(ev('before', '2026-01-01T00:00:00Z', '2026-06-01T00:00:00Z')).toBe(true); + expect(ev('after', '2026-01-01T00:00:00Z', '2026-06-01T00:00:00Z')).toBe(false); + expect(ev('before', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')).toBe(false); + expect(ev('after', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')).toBe(false); }); - it('is not met (never satisfied by mismatch) on anything but two strings', () => { - expect(evaluateOperator('not_contains', ['a'], 'b')).toBe(false); - expect(evaluateOperator('not_contains', 5, '5')).toBe(false); - expect(evaluateOperator('not_contains', 'abc', 5)).toBe(false); + it('orders two calendar dates', () => { + expect(ev('before', '2026-03-01', '2026-03-02')).toBe(true); + expect(ev('after', '2026-03-01', '2026-03-02')).toBe(false); + }); + + // Honolulu is UTC-10: its 1 March starts at 10:00Z, so a calendar date read as UTC midnight + // would sit before that instant instead of after it. + it('reads a calendar date in the clock timezone when compared to an instant', () => { + const honolulu: Clock = { + now: new Date('2026-09-04T05:00:00Z'), + timezone: 'Pacific/Honolulu', + }; + + expect(ev('after', '2026-03-01', '2026-03-01T05:00:00Z', honolulu)).toBe(true); + expect(ev('before', '2026-03-01', '2026-03-01T05:00:00Z', honolulu)).toBe(false); + }); + + it('is not met on a non-date or an impossible date', () => { + expect(ev('before', 'soon', '2026-06-01T00:00:00Z')).toBe(false); + expect(ev('before', '2026-02-30', '2026-06-01T00:00:00Z')).toBe(false); + }); + }); + + describe('past / future (relative to the clock)', () => { + it('compares against the injected instant, not the machine clock', () => { + expect(ev('past', '2026-09-04T10:00:00Z')).toBe(true); + expect(ev('past', '2026-09-04T11:00:00Z')).toBe(false); + expect(ev('future', '2026-09-04T11:00:00Z')).toBe(true); + expect(ev('future', '2026-09-04T10:00:00Z')).toBe(false); + }); + + it('is neither past nor future at the exact instant', () => { + expect(ev('past', '2026-09-04T10:30:00Z')).toBe(false); + expect(ev('future', '2026-09-04T10:30:00Z')).toBe(false); + }); + }); + + describe('before_x_hours_ago / after_x_hours_ago', () => { + it('measures from the injected instant', () => { + expect(ev('before_x_hours_ago', '2026-09-04T08:00:00Z', 2)).toBe(true); + expect(ev('before_x_hours_ago', '2026-09-04T09:00:00Z', 2)).toBe(false); + expect(ev('after_x_hours_ago', '2026-09-04T09:00:00Z', 2)).toBe(true); + expect(ev('after_x_hours_ago', '2026-09-04T08:00:00Z', 2)).toBe(false); + }); + + it('accepts zero and fractional hours, like the list filter widget', () => { + expect(ev('before_x_hours_ago', '2026-09-04T10:00:00Z', 0)).toBe(true); + expect(ev('before_x_hours_ago', '2026-09-04T11:00:00Z', 0)).toBe(false); + expect(ev('before_x_hours_ago', '2026-09-04T08:59:00Z', 1.5)).toBe(true); + expect(ev('before_x_hours_ago', '2026-09-04T09:01:00Z', 1.5)).toBe(false); + }); + + it('is not met on a count that is not a non-negative number', () => { + expect(ev('before_x_hours_ago', '2026-09-04T08:00:00Z', -1)).toBe(false); + expect(ev('before_x_hours_ago', '2026-09-04T08:00:00Z', 'two')).toBe(false); + }); + }); + + describe('day windows (today, yesterday, previous_x_days)', () => { + // Paris is UTC+2 here: its 4 September runs from 2026-09-03T22:00Z to 2026-09-04T22:00Z. + it('reads "today" in the clock timezone, not in UTC', () => { + expect(ev('today', '2026-09-03T23:00:00Z')).toBe(true); + expect(ev('today', '2026-09-03T21:00:00Z')).toBe(false); + expect(ev('today', '2026-09-04T21:59:59Z')).toBe(true); + expect(ev('today', '2026-09-04T22:00:00Z')).toBe(false); + }); + + it('reads "yesterday" as the previous project day', () => { + expect(ev('yesterday', '2026-09-03T21:00:00Z')).toBe(true); + expect(ev('yesterday', '2026-09-03T23:00:00Z')).toBe(false); + }); + + // The toolkit's transforms emit GreaterThan for a datetime and GreaterThanOrEqual for a + // calendar date: a record stamped exactly at midnight is "today" in a list filter only when + // the column is a Dateonly, and the Decision must not say otherwise. + it('treats the very start of the window like the list filter does', () => { + expect(ev('today', '2026-09-03T22:00:00Z')).toBe(false); + expect(ev('today', '2026-09-03T22:00:00.001Z')).toBe(true); + expect(ev('today', '2026-09-04')).toBe(true); + expect(ev('yesterday', '2026-09-03')).toBe(true); + }); + + // Paris switched to summer time on 2026-03-29 at 02:00: that day is 23 hours long, and a + // window built from wall-clock arithmetic instead of zone-aware startOf would drift by an hour. + it('keeps the day bounds right across a DST change', () => { + const afterSwitch: Clock = { + now: new Date('2026-03-30T08:00:00Z'), + timezone: 'Europe/Paris', + }; + + expect(ev('yesterday', '2026-03-28T23:00:01Z', undefined, afterSwitch)).toBe(true); + expect(ev('yesterday', '2026-03-28T22:59:59Z', undefined, afterSwitch)).toBe(false); + expect(ev('yesterday', '2026-03-29T21:59:59Z', undefined, afterSwitch)).toBe(true); + expect(ev('yesterday', '2026-03-29T22:00:00Z', undefined, afterSwitch)).toBe(false); + + const onSwitchDay: Clock = { + now: new Date('2026-03-29T12:00:00Z'), + timezone: 'Europe/Paris', + }; + expect(ev('today', '2026-03-29T21:59:59Z', undefined, onSwitchDay)).toBe(true); + expect(ev('today', '2026-03-29T22:00:00Z', undefined, onSwitchDay)).toBe(false); + expect(ev('today', '2026-03-29T22:30:00Z', undefined, onSwitchDay)).toBe(false); + }); + + it('previous_x_days excludes today, previous_x_days_to_date includes it up to the instant', () => { + expect(ev('previous_x_days', '2026-09-01T12:00:00Z', 7)).toBe(true); + expect(ev('previous_x_days', '2026-09-04T09:00:00Z', 7)).toBe(false); + expect(ev('previous_x_days', '2026-08-27T22:00:01Z', 7)).toBe(true); + expect(ev('previous_x_days', '2026-08-27T21:59:59Z', 7)).toBe(false); + expect(ev('previous_x_days_to_date', '2026-09-04T09:00:00Z', 7)).toBe(true); + expect(ev('previous_x_days_to_date', '2026-09-04T11:00:00Z', 7)).toBe(false); + }); + + it('is not met on a count that is not a positive whole number', () => { + expect(ev('previous_x_days', '2026-09-01T12:00:00Z', 0)).toBe(false); + expect(ev('previous_x_days', '2026-09-03T06:00:00Z', 1.5)).toBe(false); + expect(ev('previous_x_days', '2026-09-01T12:00:00Z', 'seven')).toBe(false); + }); + + // A calendar date has no instant: read as UTC midnight it would fall before Honolulu's day + // even started, and the record's own "today" would be counted as yesterday. + it('reads a calendar date in the clock timezone', () => { + const honolulu: Clock = { + now: new Date('2026-09-04T05:00:00Z'), + timezone: 'Pacific/Honolulu', + }; + + expect(ev('today', '2026-09-03', undefined, honolulu)).toBe(true); + expect(ev('today', '2026-09-04', undefined, honolulu)).toBe(false); + expect(ev('yesterday', '2026-09-02', undefined, honolulu)).toBe(true); + }); + + it('is not met on a value that is not a date', () => { + expect(ev('today', 'now')).toBe(false); + expect(ev('today', 150)).toBe(false); + }); + + // The list filter compares a Dateonly column to the bound's calendar date, so a record dated + // today is neither "in the past" nor "before 2 hours ago" nor within the days to date. + it('reads a mid-day bound at the start of its day for a calendar date, like the list filter', () => { + expect(ev('past', '2026-09-04')).toBe(false); + expect(ev('past', '2026-09-03')).toBe(true); + expect(ev('future', '2026-09-04')).toBe(false); + expect(ev('future', '2026-09-05')).toBe(true); + expect(ev('before_x_hours_ago', '2026-09-04', 2)).toBe(false); + expect(ev('before_x_hours_ago', '2026-09-03', 2)).toBe(true); + expect(ev('previous_x_days_to_date', '2026-09-04', 7)).toBe(false); + expect(ev('previous_x_days_to_date', '2026-09-03', 7)).toBe(true); + expect(ev('previous_x_days_to_date', '2026-09-04T09:00:00Z', 7)).toBe(true); }); }); }); diff --git a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts index 3020f342ca..caea079ee6 100644 --- a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts @@ -80,6 +80,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, }; diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index c5f9c99f57..1238b0e8c1 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -211,6 +211,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index cc03afe1ff..5bc850f39c 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -126,6 +126,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, }; diff --git a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts index a117e497f2..1e39c6d25c 100644 --- a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts @@ -160,6 +160,7 @@ function makeContext( }, schemaResolver, previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, }; diff --git a/packages/workflow-executor/test/executors/step-executor-factory.test.ts b/packages/workflow-executor/test/executors/step-executor-factory.test.ts index 88e2019957..6684ef10f7 100644 --- a/packages/workflow-executor/test/executors/step-executor-factory.test.ts +++ b/packages/workflow-executor/test/executors/step-executor-factory.test.ts @@ -37,6 +37,7 @@ function makeStep(): AvailableStepExecution { permissionLevel: 'admin', tags: {}, }, + timezone: 'UTC', } as unknown as AvailableStepExecution; } diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index e8a71cbdab..25d8a60e83 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -161,6 +161,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, }; diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index 4dec261274..e89a67f31c 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -154,6 +154,7 @@ function makeContext( }, schemaResolver: new SchemaResolver(schemaCache, workflowPort, runId, 1), previousSteps: [], + timezone: 'UTC', logger: jest.fn(), ...overrides, }; diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index d40b5af2ae..c4f45fcb59 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -254,6 +254,7 @@ function buildPendingStep( runId: 'run-1', stepId: 'step-1', stepIndex: 0, + timezone: 'UTC', collectionId: 'col-1', triggerType: TriggerType.Manual, baseRecordRef: BASE_RECORD_REF, diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index dbcc8913dd..ecee094cc2 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -168,6 +168,7 @@ function makePendingStep( runId: 'run-1', stepId: 'step-1', stepIndex: 0, + timezone: 'UTC', collectionId: 'col-1', triggerType: TriggerType.Manual, baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, diff --git a/packages/workflow-executor/test/types/step-definition.test.ts b/packages/workflow-executor/test/types/step-definition.test.ts index 36a6dc8758..7baf0bd844 100644 --- a/packages/workflow-executor/test/types/step-definition.test.ts +++ b/packages/workflow-executor/test/types/step-definition.test.ts @@ -90,27 +90,29 @@ describe('ConditionStepDefinitionSchema deterministic conditions', () => { }); // value supplied so the failure is the operator enum, not the value-less-operator refinement. - it('rejects an unknown operator at the schema boundary', () => { - const result = ConditionStepDefinitionSchema.safeParse({ - ...base, - executionType: 'manual', - preRecordedArgs: { - ...preRecordedArgs, - optionConditions: [ - { - option: 'High value', - aggregator: 'and', - conditions: [ - { sourceStepId: 'get-data-1', fieldName: 'amount', operator: 'ilike', value: '%x%' }, - ], - }, - ], - }, - }); + // The last three were in the contract before PRD-1147 and are refused since. + it.each(['ilike', 'not_in', 'greater_than_or_equal', 'less_than_or_equal'])( + 'rejects the unknown operator "%s" at the schema boundary', + operator => { + const result = ConditionStepDefinitionSchema.safeParse({ + ...base, + executionType: 'manual', + preRecordedArgs: { + ...preRecordedArgs, + optionConditions: [ + { + option: 'High value', + aggregator: 'and', + conditions: [{ sourceStepId: 'get-data-1', fieldName: 'amount', operator, value: 1 }], + }, + ], + }, + }); - expect(result.success).toBe(false); - expect(JSON.stringify(!result.success && result.error.issues)).toContain('operator'); - }); + expect(result.success).toBe(false); + expect(JSON.stringify(!result.success && result.error.issues)).toContain('operator'); + }, + ); // An empty reference resolves to "not found" → met: null → not met → the step routes to the // fallback. Same silent-fallback failure the value-less-operator refinement exists to prevent. @@ -210,8 +212,45 @@ describe('ConditionStepDefinitionSchema deterministic conditions', () => { expect(JSON.stringify(!result.success && result.error.issues)).toContain('optionConditions'); }); - it.each(['equal', 'not_equal', 'greater_than', 'in', 'contains'])( - 'rejects a "%s" condition with no value', + it.each([ + 'equal', + 'not_equal', + 'greater_than', + 'less_than', + 'in', + 'includes_all', + 'contains', + 'not_contains', + 'starts_with', + 'ends_with', + 'i_contains', + 'before', + 'after', + 'previous_x_days', + 'previous_x_days_to_date', + 'before_x_hours_ago', + 'after_x_hours_ago', + ])('rejects a "%s" condition with no value', operator => { + const result = ConditionStepDefinitionSchema.safeParse({ + ...base, + executionType: 'manual', + preRecordedArgs: { + ...preRecordedArgs, + optionConditions: [ + { + option: 'High value', + aggregator: 'and', + conditions: [{ sourceStepId: 'get-data-1', fieldName: 'amount', operator }], + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); + + it.each(['present', 'blank', 'past', 'future', 'today', 'yesterday'])( + 'accepts a value-less "%s" condition', operator => { const result = ConditionStepDefinitionSchema.safeParse({ ...base, @@ -221,36 +260,17 @@ describe('ConditionStepDefinitionSchema deterministic conditions', () => { optionConditions: [ { option: 'High value', - aggregator: 'and', + aggregator: 'or', conditions: [{ sourceStepId: 'get-data-1', fieldName: 'amount', operator }], }, ], }, }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }, ); - it('accepts a value-less condition for present/blank operators', () => { - const result = ConditionStepDefinitionSchema.safeParse({ - ...base, - executionType: 'manual', - preRecordedArgs: { - ...preRecordedArgs, - optionConditions: [ - { - option: 'High value', - aggregator: 'or', - conditions: [{ sourceStepId: 'get-data-1', fieldName: 'amount', operator: 'present' }], - }, - ], - }, - }); - - expect(result.success).toBe(true); - }); - it('still accepts a condition with no preRecordedArgs at all', () => { expect( ConditionStepDefinitionSchema.safeParse({ ...base, executionType: 'fully-automated' })