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,15 @@ 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.
// This branch is the main path, not an edge case: 635 of 85457 projects carry a timezone
// (0.7%, measured on production in September 2026), so almost every Decision reads its
// relative dates in UTC — an hour or two away from the day the list filter shows the same
// user, since that one follows the browser.
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
Loading
Loading