Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion packages/workflow-executor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
29 changes: 29 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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).
Expand Down
Loading
Loading