diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 49fa7e3c6e..a218d8775c 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -43,7 +43,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p - **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, absent or not an IANA name). **`UTC` is the main path, not an edge case**: 0.7% of projects carry a timezone (635/85457, measured September 2026), so a Decision's "today" is usually UTC's day while the list filter shows the browser's. The machine's zone is never used: the fleet runs several instances, and the same run must route the same on each. Day and calendar-period windows (`today`, `yesterday`, `previous_x_days`, `previous_x_days_to_date`, and `previous_week`/`month`/`quarter`/`year` with their `_to_date` forms, which read the whole previous period or the current one up to the clock) 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. `fillFormWithAi` runs at most 10 AI passes (one per level of fields revealed by change hooks); an AI value for an Enum is kept only if it is among its options, and once kept a value is never rewritten, except one retry when the form no longer holds it (emptied by the agent, or out of options a hook swapped); choice widgets are not checked against `allowedValues`, a dynamic-search dropdown only lists the current suggestions, so a model rewording a value cannot run the loop to the cap. A Warn names the fields left out at the cap; the Full AI fallback logs the required fields still empty (names only). -- **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. Inside a sub-workflow call the sentinel resolves to the record the calling step pinned, not the record the run was launched on: `toAvailableStepExecution` rebuilds the innermost open call from the raw `workflowHistory` into `AvailableStepExecution.callScope` (`selectedRecordStepId`, `pinnedFrameStepIndexes`, `currentFrameStepIndexes`, `calledWorkflowCollectionName`, `isPinned`, all optional; a pin to a real step id is looked for only among `currentFrameStepIndexes`, sent at the root too once any call has run), and `resolveSourceRecordRef` reads it — a call pinning nothing still falls back to `baseRecordRef` unchecked, exactly as before pins existed, a pin of `WORKFLOW_START_STEP_ID` keeps walking outward through every enclosing call that also pins it, one that walks all the way out pins the run's record and is checked like any other pinned record (`isPinned` is what tells it from a call pinning nothing, since it sends no `selectedRecordStepId`), and a pinned record whose collection is not the called workflow's raises `SourceRecordCollectionMismatchError` instead of being used, naming both collections and the Sub-workflow step to set the record on — `SourceRecordMissingError` would say the source loaded nothing, which is untrue when a record was found. The call's own pin is the one place an empty value reads as **absent** rather than as a lost target: it arrives on the wire from an orchestrator whose vocabulary predates it, and `CallScopeSchema` rejects the empty string, so keeping it would report the whole run malformed on every poll rather than failing the one step. The pin is matched only among `callScope.pinnedFrameStepIndexes`, the steps of the frame that wrote it — `previousSteps` flattens every frame and a step id is unique only inside its own workflow, so any other workflow repeating one (a copy of its caller, a call on itself, a sibling call that already closed) would otherwise shadow the step the caller pinned. It is a set rather than an upper bound because a frame is not a span: a nested call's body sits between its steps. A pin that matches no step raises `SourceRecordStepNotReachedError`, which points at the Sub-workflow step, since the called workflow's own step is configured correctly. `baseRecordRef`, `getAvailableRecordRefs` and the activity-log target are untouched. +- **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`. A pre-recorded `value` goes through `coerceFieldValue` like the AI and override paths; one its field's type rejects → `InvalidPreRecordedValueError` (`configuration`, names the field; the reason stays in the technical message), never a raw write. A relationship field, pinned (value or not) or overridden → `RelationshipFieldNotUpdatableError` (`configuration`): `updateRecord` sends attributes only and the agent's `RecordValidator` rejects a relation there, `null` included, so nothing can unlink. A relationship the AI names → `FieldNotFoundError`, as for any field the AI chose. An override whose field left the schema during the pause → `PendingFieldNotFoundError` (`configuration`). Coercion (every path): Date takes `Z` or an offset, never no zone; Json takes any JSON value (valid JSON string, object, array, boolean, number); a decimal string on a Number becomes a number, except an integer string beyond `Number.MAX_SAFE_INTEGER`, kept as the string the agent's bigint check accepts; a Point takes `[lon, lat]` or `"lon,lat"` and is written as the `"lon,lat"` string, the only shape the agent's `TypeGetter` takes (the AI tool schema keeps the array). A person-written value (pre-recorded, or sent at accept — the run view always sends the pending value back) is coerced `strict`: a Number takes only a number or a decimal string; a zero fraction is dropped (`"9007199254740993.0"` → the integer string), and a real fraction a double cannot hold digit for digit is rejected rather than rounded (`"123456789.123456789"`, `"4503599627370496.5"`). An AI value, or an accept sending no value, keeps `z.coerce.number()` (`""` → 0, `true` → 1). The AI tool schemas keep the front's shapes (no datetime offset, Json as a string); `invokeWithTool` does not parse the args, so `coerceFieldValue` is the runtime check on AI values. 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. Inside a sub-workflow call the sentinel resolves to the record the calling step pinned, not the record the run was launched on: `toAvailableStepExecution` rebuilds the innermost open call from the raw `workflowHistory` into `AvailableStepExecution.callScope` (`selectedRecordStepId`, `pinnedFrameStepIndexes`, `currentFrameStepIndexes`, `calledWorkflowCollectionName`, `isPinned`, all optional; a pin to a real step id is looked for only among `currentFrameStepIndexes`, sent at the root too once any call has run), and `resolveSourceRecordRef` reads it — a call pinning nothing still falls back to `baseRecordRef` unchecked, exactly as before pins existed, a pin of `WORKFLOW_START_STEP_ID` keeps walking outward through every enclosing call that also pins it, one that walks all the way out pins the run's record and is checked like any other pinned record (`isPinned` is what tells it from a call pinning nothing, since it sends no `selectedRecordStepId`), and a pinned record whose collection is not the called workflow's raises `SourceRecordCollectionMismatchError` instead of being used, naming both collections and the Sub-workflow step to set the record on — `SourceRecordMissingError` would say the source loaded nothing, which is untrue when a record was found. The call's own pin is the one place an empty value reads as **absent** rather than as a lost target: it arrives on the wire from an orchestrator whose vocabulary predates it, and `CallScopeSchema` rejects the empty string, so keeping it would report the whole run malformed on every poll rather than failing the one step. The pin is matched only among `callScope.pinnedFrameStepIndexes`, the steps of the frame that wrote it — `previousSteps` flattens every frame and a step id is unique only inside its own workflow, so any other workflow repeating one (a copy of its caller, a call on itself, a sibling call that already closed) would otherwise shadow the step the caller pinned. It is a set rather than an upper bound because a frame is not a span: a nested call's body sits between its steps. A pin that matches no step raises `SourceRecordStepNotReachedError`, which points at the Sub-workflow step, since the called workflow's own step is configured correctly. `baseRecordRef`, `getAvailableRecordRefs` and the activity-log target are untouched. ## Invariants (read before changing executors) diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 71161500ef..eef3f7218d 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -277,6 +277,25 @@ export class PinnedArgNotFoundError extends WorkflowConfigurationError { } } +export class RelationshipFieldNotUpdatableError extends WorkflowConfigurationError { + constructor(fieldDisplayName: string, collectionName: string) { + super( + `Field "${fieldDisplayName}" in collection "${collectionName}" is a relationship`, + `The "${fieldDisplayName}" field is a relationship, which this step can't update. ` + + 'Edit the step to pick another field.', + ); + } +} + +export class PendingFieldNotFoundError extends WorkflowConfigurationError { + constructor(name: string, collectionName: string) { + super( + `Field "${name}" awaiting confirmation not found in collection "${collectionName}"`, + 'The field this step was about to update no longer exists on this record.', + ); + } +} + export class FieldTypeMissingError extends WorkflowConfigurationError { constructor(name: string, collectionName: string) { super( @@ -639,6 +658,16 @@ export class InvalidPreRecordedArgsError extends WorkflowConfigurationError { } } +export class InvalidPreRecordedValueError extends WorkflowConfigurationError { + constructor(fieldDisplayName: string, reason: string) { + super( + `Invalid pre-recorded value for field "${fieldDisplayName}": ${reason}`, + `The value set on this step can't be written to the "${fieldDisplayName}" field. ` + + 'Edit the step to set a valid value.', + ); + } +} + // A "Related to" / "On record" source step ran but loaded no record, so the step that uses it has // no source to act on ("no source record"). Distinct from a bad config — the // user can continue without. Wording is step-type-neutral (shared by load-related and trigger-action). diff --git a/packages/workflow-executor/src/executors/update-record-step-executor.ts b/packages/workflow-executor/src/executors/update-record-step-executor.ts index 78310c4aa8..12dc1e76e8 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -1,3 +1,4 @@ +import type { WorkflowExecutorError } from '../errors'; import type { StepExecutionResult } from '../types/execution-context'; import type { FieldWithValue, UpdateRecordStepExecutionData } from '../types/step-execution-data'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/validated/collection'; @@ -10,8 +11,11 @@ import { FieldNotFoundError, FieldTypeMissingError, InvalidPreRecordedArgsError, + InvalidPreRecordedValueError, NoWritableFieldsError, + PendingFieldNotFoundError, PinnedArgNotFoundError, + RelationshipFieldNotUpdatableError, StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; @@ -41,6 +45,88 @@ const jsonStringSchema = z ) .describe('JSON content as a valid JSON string'); +const jsonValueSchema = z.union([ + jsonStringSchema, + z.record(z.string(), z.unknown()), + z.array(z.unknown()), + z.boolean(), + z.number(), +]); + +const DECIMAL_PATTERN = /^-?\d+(\.\d+)?$/; +const INTEGER_PATTERN = /^-?\d+$/; + +// The agent takes a bigint column's value as an integer string, which a double would round. +function toExactNumber(decimal: string): number | string { + const asNumber = Number(decimal); + + return INTEGER_PATTERN.test(decimal) && !Number.isSafeInteger(asNumber) ? decimal : asNumber; +} + +const ZERO_FRACTION_PATTERN = /\.0+$/; + +function significantDigits(decimal: string): string { + return decimal + .replace(/e.*$/i, '') + .replace(/\D/g, '') + .replace(/^0+|0+$/g, ''); +} + +function toStrictNumber(decimal: string): number | string { + const integral = decimal.replace(ZERO_FRACTION_PATTERN, ''); + + if (INTEGER_PATTERN.test(integral)) return toExactNumber(integral); + + const asNumber = Number(decimal); + + return significantDigits(decimal) === significantDigits(String(asNumber)) ? asNumber : NaN; +} + +function isPointString(val: string): boolean { + const coordinates = val.split(','); + + return ( + coordinates.length === 2 && + coordinates.every(coordinate => coordinate.trim() !== '' && Number.isFinite(Number(coordinate))) + ); +} + +function isPointArray(val: unknown): val is [number, number] { + return ( + Array.isArray(val) && + val.length === 2 && + val.every(coordinate => typeof coordinate === 'number' && Number.isFinite(coordinate)) + ); +} + +// The agent takes a Point only as the string "longitude,latitude". +const pointValueSchema = z.preprocess( + val => (isPointArray(val) ? val.join(',') : val), + z.custom(val => typeof val === 'string' && isPointString(val), { + message: 'expected [longitude, latitude] or "longitude,latitude"', + }), +); + +const NUMBER_FORMAT_MESSAGE = 'expected a number or a decimal string, e.g. 12, -3 or 12.5'; + +function buildNumberCoercion(strict: boolean): z.ZodTypeAny { + return z.preprocess( + val => { + if (typeof val === 'string' && DECIMAL_PATTERN.test(val.trim())) { + return strict ? toStrictNumber(val.trim()) : toExactNumber(val.trim()); + } + + return strict || typeof val === 'number' ? val : Number(val); + }, + z.custom( + val => + (typeof val === 'number' && Number.isFinite(val)) || + (typeof val === 'string' && INTEGER_PATTERN.test(val)), + { message: NUMBER_FORMAT_MESSAGE }, + ), + ); +} + // `forAiTool` = the schema is bound into an AI tool (langchain converts it to JSON Schema). The // Boolean coercion uses `z.preprocess`, which langchain's cross-package transform-stripping misses // when the workspace resolves two zod copies (root vs nested) → "Transforms cannot be represented @@ -50,6 +136,7 @@ function buildZodSchemaForPrimitive( type: string, enumValues?: string[], forAiTool = false, + strict = false, ): z.ZodTypeAny { switch (type) { case 'Boolean': @@ -63,11 +150,13 @@ function buildZodSchemaForPrimitive( return val; }, z.boolean()); case 'Date': - return z.iso.datetime().describe('ISO 8601 datetime, e.g. 2024-06-01T00:00:00Z'); + return z.iso + .datetime({ offset: !forAiTool }) + .describe('ISO 8601 datetime, e.g. 2024-06-01T00:00:00Z'); case 'Dateonly': return z.iso.date().describe('ISO 8601 date, e.g. 2024-06-01'); case 'Number': - return z.coerce.number(); + return forAiTool ? z.coerce.number() : buildNumberCoercion(strict); case 'Enum': if (enumValues && enumValues.length >= 2) { return z.enum(enumValues as [string, string, ...string[]]); @@ -77,9 +166,11 @@ function buildZodSchemaForPrimitive( return z.string(); case 'Json': - return jsonStringSchema; + return forAiTool ? jsonStringSchema : jsonValueSchema; case 'Point': - return z.array(z.number()).length(2).describe('[longitude, latitude]'); + return forAiTool + ? z.array(z.number()).length(2).describe('[longitude, latitude]') + : pointValueSchema; // String, Uuid, Time, Binary, Timeonly, File → plain string default: return z.string(); @@ -90,6 +181,7 @@ function buildZodSchemaForField( field: FieldSchema, collectionName: string, forAiTool = false, + strict = false, ): z.ZodTypeAny { const { type, enumValues } = field; @@ -99,38 +191,50 @@ function buildZodSchemaForField( throw new FieldTypeMissingError(field.displayName, collectionName); } + const jsonSchema = forAiTool ? jsonStringSchema : jsonValueSchema; + if (Array.isArray(type)) { // Nested array (e.g. [['String']]) → treat as opaque JSON. - if (Array.isArray(type[0])) return z.array(jsonStringSchema); + if (Array.isArray(type[0])) return z.array(jsonSchema); - return z.array(buildZodSchemaForPrimitive(type[0] as string, enumValues, forAiTool)); + return z.array(buildZodSchemaForPrimitive(type[0] as string, enumValues, forAiTool, strict)); } if (typeof type === 'object' && type !== null) { - return jsonStringSchema; + return jsonSchema; } - return buildZodSchemaForPrimitive(type as string, enumValues, forAiTool); + return buildZodSchemaForPrimitive(type as string, enumValues, forAiTool, strict); +} + +interface CoerceOptions { + strict?: boolean; + toError?: (fieldDisplayName: string, reason: string) => WorkflowExecutorError; } -// Coerce a user-overridden value to the field's native type before updating the record. -// The HTTP schema accepts `unknown`, so the override may be a boolean or an array; this turns -// it into the type the datasource expects, and throws a StepStateError on mismatch. function coerceFieldValue( - fieldSchema: FieldSchema | undefined, + fieldSchema: FieldSchema, value: unknown, collectionName: string, + { + strict = false, + toError = (name, reason) => new StepStateError(`Invalid value for field "${name}": ${reason}`), + }: CoerceOptions = {}, ): unknown { - // Field not found, relationship (type intentionally null), or explicit null → nothing to coerce. - if (!fieldSchema || fieldSchema.isRelationship || value === null) return value; + if (fieldSchema.isRelationship) { + throw new RelationshipFieldNotUpdatableError(fieldSchema.displayName, collectionName); + } + + if (value === null) return value; - const parsed = buildZodSchemaForField(fieldSchema, collectionName).safeParse(value); + const parsed = buildZodSchemaForField(fieldSchema, collectionName, false, strict).safeParse( + value, + ); if (!parsed.success) { - throw new StepStateError( - `Invalid value for field "${fieldSchema.displayName}": ${parsed.error.issues - .map(issue => issue.message) - .join(', ')}`, + throw toError( + fieldSchema.displayName, + parsed.error.issues.map(issue => issue.message).join(', '), ); } @@ -166,18 +270,19 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor(pending, async exec => { - const { selectedRecordRef, pendingData, userConfirmation } = exec; + const { selectedRecordRef, userConfirmation } = exec; + const pendingData = exec.pendingData!; // A user override of `null` (clearing the field) must win over the AI suggestion, so // distinguish "no override" (undefined) from "override to null". - const rawValue = - userConfirmation?.value !== undefined ? userConfirmation.value : pendingData!.value; + const isOverridden = userConfirmation?.value !== undefined; + const rawValue = isOverridden ? userConfirmation.value : pendingData.value; const target: UpdateTarget = { selectedRecordRef, - ...pendingData!, + ...pendingData, // The value comes from an `unknown` HTTP value (may be a boolean or array), so coerce // it to the field's native type before updating. Idempotent on already-typed values. - value: await this.coerceOverride(selectedRecordRef, pendingData, rawValue), + value: await this.coerceOverride(selectedRecordRef, pendingData, rawValue, isOverridden), }; return this.resolveAndUpdate(target, exec); @@ -190,13 +295,20 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const fieldSchema = this.findFieldByTechnicalName(schema, pendingData?.name); + const fieldSchema = this.findFieldByTechnicalName(schema, pendingData.name); + + if (!fieldSchema) { + throw new PendingFieldNotFoundError(pendingData.name, schema.collectionName); + } - return coerceFieldValue(fieldSchema, value, selectedRecordRef.collectionName); + return coerceFieldValue(fieldSchema, value, selectedRecordRef.collectionName, { + strict: isOverridden, + }); } private async handleFirstCall(): Promise { @@ -223,29 +335,33 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor new InvalidPreRecordedValueError(name, reason), + }) + : await this.selectValueForField(schema, field, step.prompt); + } else { + ({ field, value } = await this.selectFieldAndValue(schema, step.prompt)); } const target: UpdateTarget = { @@ -316,7 +432,7 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { + ): Promise<{ field: FieldSchema; value: unknown }> { const tool = this.buildUpdateFieldTool(schema); const messages = [ this.buildContextMessage(), @@ -333,17 +449,15 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor(messages, tool); const fieldName = this.resolveAiFieldName(schema, input.fieldName); + const field = this.findFieldByTechnicalName(schema, fieldName); + + if (!field || field.isRelationship) { + throw new FieldNotFoundError(fieldName, schema.collectionName); + } // The AI tool schema is JSON-Schema-safe (plain z.boolean() for Boolean), so it does not coerce // a stray string — normalize to the field's native type, matching the pinned-field path. - return { - fieldName, - value: coerceFieldValue( - this.findFieldByTechnicalName(schema, fieldName), - input.value, - schema.collectionName, - ), - }; + return { field, value: coerceFieldValue(field, input.value, schema.collectionName) }; } // Field is pinned at build time; only the value is AI-resolved from the prompt/workflow context. 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 e2747a942b..d43d5f646b 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 @@ -1850,6 +1850,518 @@ describe('UpdateRecordStepExecutor', () => { }), ); }); + + describe('build-time value coercion', () => { + const prerecordedFields: CollectionSchema['fields'] = [ + { + fieldName: 'status', + displayName: 'Status', + isRelationship: false, + type: 'Enum', + enumValues: ['active', 'inactive'], + }, + { fieldName: 'closedAt', displayName: 'Closed At', isRelationship: false, type: 'Date' }, + { fieldName: 'age', displayName: 'Age', isRelationship: false, type: 'Number' }, + { fieldName: 'isActive', displayName: 'Is Active', isRelationship: false, type: 'Boolean' }, + { fieldName: 'legacy', displayName: 'Legacy', isRelationship: false }, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true, type: null }, + { fieldName: 'metadata', displayName: 'Metadata', isRelationship: false, type: 'Json' }, + { fieldName: 'location', displayName: 'Location', isRelationship: false, type: 'Point' }, + { + fieldName: 'address', + displayName: 'Address', + isRelationship: false, + type: { street: 'String' }, + }, + ]; + + function makePrerecordedContext( + fieldName: string, + value: unknown, + executionType: UpdateRecordStepDefinition['executionType'] = StepExecutionMode.FullyAutomated, + ) { + const runStore = makeMockRunStore(); + const agentPort = makeMockAgentPort(); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema({ fields: prerecordedFields }), + }); + const context = makeContext({ + runStore, + agentPort, + workflowPort, + stepDefinition: makeStep({ executionType, preRecordedArgs: { fieldName, value } }), + }); + + return { executor: new UpdateRecordStepExecutor(context), agentPort, runStore, context }; + } + + it('writes a valid enum value', async () => { + const { executor, agentPort, context } = makePrerecordedContext('status', 'inactive'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { status: 'inactive' } }, + context.user, + ); + }); + + it('fails as a configuration error naming the field on an invalid enum value', async () => { + const { executor, agentPort } = makePrerecordedContext('status', 'archived'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Status" field. ' + + 'Edit the step to set a valid value.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('rejects an invalid enum value before asking for confirmation', async () => { + const { executor, runStore } = makePrerecordedContext( + 'status', + 'archived', + StepExecutionMode.AutomatedWithConfirmation, + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(runStore.saveStepExecution).not.toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ pendingData: expect.anything() }), + ); + }); + + it('writes a valid ISO datetime', async () => { + const { executor, agentPort, context } = makePrerecordedContext( + 'closedAt', + '2026-06-01T00:00:00Z', + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { closedAt: '2026-06-01T00:00:00Z' } }, + context.user, + ); + }); + + it('writes an ISO datetime carrying a timezone offset', async () => { + const { executor, agentPort, context } = makePrerecordedContext( + 'closedAt', + '2026-06-01T10:00:00+02:00', + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { closedAt: '2026-06-01T10:00:00+02:00' } }, + context.user, + ); + }); + + it('fails as a configuration error on a datetime without a timezone', async () => { + const { executor, agentPort } = makePrerecordedContext('closedAt', '2026-06-01T10:00:00'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('fails as a configuration error on an impossible date', async () => { + const { executor, agentPort } = makePrerecordedContext('closedAt', '2026-02-30T00:00:00Z'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Closed At" field. ' + + 'Edit the step to set a valid value.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('coerces a numeric string to a number', async () => { + const { executor, agentPort, context } = makePrerecordedContext('age', '12'); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: 12 } }, + context.user, + ); + }); + + it.each(['', ' '])( + 'fails as a configuration error on the blank string %p instead of writing 0', + async value => { + const { executor, agentPort } = makePrerecordedContext('age', value); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Age" field. ' + + 'Edit the step to set a valid value.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true, [], ['12']])( + 'fails as a configuration error on the non-numeric %p instead of coercing it', + async value => { + const { executor, agentPort } = makePrerecordedContext('age', value); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Age" field. ' + + 'Edit the step to set a valid value.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }, + ); + + it.each(['42', null, undefined])( + 'fails as a configuration error when the pinned field is a relationship (value %p)', + async value => { + const { executor, agentPort } = makePrerecordedContext('orders', value); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The "Orders" field is a relationship, which this step can\'t update. ' + + 'Edit the step to pick another field.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['12345678901234567890', '12345678901234567890'], + ['-12345678901234567890', '-12345678901234567890'], + ['0.0000001', 1e-7], + ['9007199254740993.0', '9007199254740993'], + ['-12345678901234567890.000', '-12345678901234567890'], + ['12.0', 12], + ['12.50', 12.5], + ['007', 7], + ['-3', -3], + ])('writes the decimal string %p as %p without losing precision', async (value, written) => { + const { executor, agentPort, context } = makePrerecordedContext('age', value); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: written } }, + context.user, + ); + }); + + it.each([ + '0x10', + '0b11', + '0o17', + '1e3', + 'Infinity', + '12abc', + '12345678901234567890.5', + '4503599627370496.5', + '123456789.123456789', + '0.1000000000000000000001', + '1.00000000000000000001', + ])( + 'fails as a configuration error on the string %p, which no Number can take exactly', + async value => { + const { executor, agentPort } = makePrerecordedContext('age', value); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Age" field. ' + + 'Edit the step to set a valid value.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }, + ); + + it('names the expected number format in the technical message only', async () => { + const { executor, context } = makePrerecordedContext('age', 'abc'); + + const result = await executor.execute(); + + expect(result.stepOutcome.error).toBe( + 'The value set on this step can\'t be written to the "Age" field. ' + + 'Edit the step to set a valid value.', + ); + expect(context.logger).toHaveBeenCalledWith( + 'Error', + 'Invalid pre-recorded value for field "Age": ' + + 'expected a number or a decimal string, e.g. 12, -3 or 12.5', + expect.anything(), + ); + }); + + it.each([[{ a: 1 }], [[1, 2]], ['{"a":1}'], [true], [123]])( + 'writes the Json value %p as is', + async value => { + const { executor, agentPort, context } = makePrerecordedContext('metadata', value); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { metadata: value } }, + context.user, + ); + }, + ); + + it.each([ + ['2.35,48.85', '2.35,48.85'], + [[2.35, 48.85], '2.35,48.85'], + ])('writes the Point value %p as the string %p the agent takes', async (value, written) => { + const { executor, agentPort, context } = makePrerecordedContext('location', value); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { location: written } }, + context.user, + ); + }); + + it.each([['abc'], ['1,2,3'], ['1,'], [[1]]])( + 'fails as a configuration error on the Point value %p', + async value => { + const { executor, agentPort, context } = makePrerecordedContext('location', value); + + const result = await executor.execute(); + + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(context.logger).toHaveBeenCalledWith( + 'Error', + 'Invalid pre-recorded value for field "Location": ' + + 'expected [longitude, latitude] or "longitude,latitude"', + expect.anything(), + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }, + ); + + it('writes an object value as is on a composite field', async () => { + const { executor, agentPort, context } = makePrerecordedContext('address', { + street: 'Main St', + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { address: { street: 'Main St' } } }, + context.user, + ); + }); + + it('fails as a configuration error on a Json string that is not valid JSON', async () => { + const { executor, agentPort } = makePrerecordedContext('metadata', '{not json'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('fails as a configuration error when the pinned field has no column type', async () => { + const { executor, agentPort } = makePrerecordedContext('legacy', 'anything'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + "This field can't be updated because its type is missing from the schema. " + + 'Contact your administrator if the problem persists.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('writes 0', async () => { + const { executor, agentPort, context } = makePrerecordedContext('age', 0); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: 0 } }, + context.user, + ); + }); + + it.each([false, 'false'])('writes boolean false from %p', async value => { + const { executor, agentPort, context } = makePrerecordedContext('isActive', value); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: false } }, + context.user, + ); + }); + + it('passes null through to clear the field', async () => { + const { executor, agentPort, context } = makePrerecordedContext('closedAt', null); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { closedAt: null } }, + context.user, + ); + }); + }); + }); + + describe('AI-returned value coercion', () => { + function makeAiContext(field: CollectionSchema['fields'][number], aiValue: unknown) { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + input: { fieldName: field.displayName, value: aiValue, reasoning: 'r' }, + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema({ fields: [field] }), + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + return { executor: new UpdateRecordStepExecutor(context), agentPort, context }; + } + + const ageField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + + it.each([ + ['', 0], + [true, 1], + ])( + 'writes the AI value %p on a Number field as %p, as the front does', + async (aiValue, written) => { + const { executor, agentPort } = makeAiContext(ageField, aiValue); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: written } }, + expect.objectContaining({ id: 1 }), + ); + }, + ); + + it.each([ + [ + 'Date', + { fieldName: 'closedAt', displayName: 'Closed At', type: 'Date' as const }, + '2026-06-01T10:00:00+02:00', + ], + ['Json object', { fieldName: 'meta', displayName: 'Meta', type: 'Json' as const }, { a: 1 }], + ['Json boolean', { fieldName: 'meta', displayName: 'Meta', type: 'Json' as const }, true], + ['Json number', { fieldName: 'meta', displayName: 'Meta', type: 'Json' as const }, 123], + ['Number', ageField, '12345678901234567890'], + ])('writes the AI %s value %p as is', async (_label, field, aiValue) => { + const { executor, agentPort } = makeAiContext({ ...field, isRelationship: false }, aiValue); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { [field.fieldName]: aiValue } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('writes an AI Point array as the string the agent takes', async () => { + const { executor, agentPort } = makeAiContext( + { fieldName: 'location', displayName: 'Location', isRelationship: false, type: 'Point' }, + [2.35, 48.85], + ); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { location: '2.35,48.85' } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('asks to rephrase the prompt, not to edit the step, when the AI names a relationship', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + input: { fieldName: 'Orders', value: null, reasoning: 'r' }, + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema({ + fields: [ + ageField, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true, type: null }, + ], + }), + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + + const result = await new UpdateRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).not.toBe('configuration'); + expect(result.stepOutcome.error).toBe( + "The AI selected a field that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('names the expected number format when an AI value is not a number', async () => { + const { executor, agentPort, context } = makeAiContext(ageField, 'abc'); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(context.logger).toHaveBeenCalledWith( + 'Error', + 'Invalid value for field "Age": expected a number or a decimal string, e.g. 12, -3 or 12.5', + expect.anything(), + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); }); describe('buildUpdateFieldTool — type-specific schemas', () => { @@ -1904,6 +2416,23 @@ describe('UpdateRecordStepExecutor', () => { ).toThrow(); }); + it('Date: the tool schema sent to the model accepts Z and rejects an offset, like the front tool', async () => { + const schema = await getToolSchema([ + { fieldName: 'created_at', displayName: 'Created At', isRelationship: false, type: 'Date' }, + ]); + + expect( + schema.parse({ + input: { fieldName: 'Created At', value: '2024-06-01T10:00:00Z', reasoning: 'r' }, + }).input.value, + ).toBe('2024-06-01T10:00:00Z'); + expect( + schema.safeParse({ + input: { fieldName: 'Created At', value: '2024-06-01T10:00:00+02:00', reasoning: 'r' }, + }).error?.issues, + ).toEqual([expect.objectContaining({ path: ['input', 'value'], format: 'datetime' })]); + }); + it('Dateonly: accepts ISO 8601 date, rejects datetime and free text', async () => { const schema = await getToolSchema([ { @@ -2437,6 +2966,27 @@ describe('UpdateRecordStepExecutor', () => { expect(agentPort.updateRecord).not.toHaveBeenCalled(); }); + it('fails on a blank override to a Number field instead of writing 0', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, 30, { + userConfirmed: true, + value: '', + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + it('coerces the AI/preRecordedArg value when the user confirms without overriding', async () => { // pendingData.value is a raw "true" string (e.g. from preRecordedArgs); no user override. const { executor, agentPort } = makeCoercionContext(booleanField, 'true', { @@ -2465,27 +3015,198 @@ describe('UpdateRecordStepExecutor', () => { ); }); - it('passes the value through unchanged when the field type is null (relationship)', async () => { - const nullTypeField = { + it('fails on an override to a relationship field instead of writing it raw', async () => { + const relationshipField = { fieldName: 'orders', displayName: 'Orders', isRelationship: true, type: null, }; - const opaque = { some: 'object' }; - const { executor, agentPort } = makeCoercionContext(nullTypeField, null, { + const { executor, agentPort } = makeCoercionContext(relationshipField, null, { userConfirmed: true, - value: opaque, + value: { some: 'object' }, }); - await executor.execute(); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The "Orders" field is a relationship, which this step can\'t update. ' + + 'Edit the step to pick another field.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('fails on a null override to a relationship field, which the agent cannot unlink', async () => { + const relationshipField = { + fieldName: 'owner', + displayName: 'Owner', + isRelationship: true, + type: null, + }; + const { executor, agentPort } = makeCoercionContext(relationshipField, null, { + userConfirmed: true, + value: null, + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The "Owner" field is a relationship, which this step can\'t update. ' + + 'Edit the step to pick another field.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('fails on a hexadecimal override to a Number field instead of writing 16', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, 30, { + userConfirmed: true, + value: '0x10', + }); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('coerces a pending blank Number value leniently when a client accepts without sending a value', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, '', { + userConfirmed: true, + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith( - { collection: 'customers', id: [42], values: { orders: opaque } }, + { collection: 'customers', id: [42], values: { age: 0 } }, expect.objectContaining({ id: 1 }), ); }); + it.each([ + ['Date' as const, '2026-06-01T10:00:00+02:00'], + ['Json' as const, { a: 1 }], + ['Number' as const, '12345678901234567890'], + ])('writes a pending %s value accepted as is', async (type, pendingValue) => { + const field = { fieldName: 'value', displayName: 'Value', isRelationship: false, type }; + const { executor, agentPort } = makeCoercionContext(field, pendingValue, { + userConfirmed: true, + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { value: pendingValue } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('coerces strictly when the run view accepts by sending the pending value back', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, '', { + userConfirmed: true, + value: '', + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('rejects an override with a fraction beyond the safe integer range instead of rounding it', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, 30, { + userConfirmed: true, + value: '9007199254740993.5', + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('keeps a pending bigint string exact when the run view sends it back', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, '12345678901234567890', { + userConfirmed: true, + value: '12345678901234567890', + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: '12345678901234567890' } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('fails without writing when the pending field was removed from the schema', async () => { + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + userConfirmation: { userConfirmed: true, value: [1, 2] }, + selectedRecordRef: makeRecordRef(), + }; + const agentPort = makeMockAgentPort(); + const context = makeContext({ + agentPort, + runStore: makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]) }), + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ fields: [booleanField] }), + }), + }); + + const result = await new UpdateRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.errorKind).toBe('configuration'); + expect(result.stepOutcome.error).toBe( + 'The field this step was about to update no longer exists on this record.', + ); + expect(context.logger).toHaveBeenCalledWith( + 'Error', + 'Field "status" awaiting confirmation not found in collection "customers"', + expect.anything(), + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + it('fails on a non-boolean string override to a Boolean field (preprocess passes it through, z.boolean rejects)', async () => { const { executor, agentPort } = makeCoercionContext(booleanField, null, { userConfirmed: true,