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
5 changes: 4 additions & 1 deletion packages/workflow-executor/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p
`StepExecutionMode` (domain enum, mapped from the server contract in `step-definition-mapper.ts`): `Manual`, `AutomatedWithConfirmation`, `FullyAutomated`.

- **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. The source record differs by step: read-record/update-record use `selectedRecordStepIndex` (a runtime index, resolved in `resolveRecordRef`); trigger-action/load-related-record use `selectedRecordStepId` — a **stable BPMN step id** (or `WORKFLOW_START_STEP_ID` sentinel) resolved by `resolveSourceRecordRef`, chosen to survive the index shifts a revision causes. Partial args supported. Unresolvable → `FieldNotFoundError`/`ActionNotFoundError`/`RelationNotFoundError`; bad shape / out-of-range index → `InvalidPreRecordedArgsError`.
- **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. `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 → `FieldNotFoundError`/`ActionNotFoundError`/`RelationNotFoundError`; bad shape / out-of-range index → `InvalidPreRecordedArgsError`.

## Invariants (read before changing executors)

Expand All @@ -45,6 +45,9 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor (this p
- *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.
- `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 who has to act on a step. They are deliberately separate vocabularies — don't map one onto the other.
- **`displayName` vs technical name** — AI tools/prompts use `displayName` (the admin-configured label end users write against), never `fieldName`. Map AI-returned display names back to technical names before any datasource op.
- **Idempotency (mutating steps: update-record, trigger-action, mcp)** — write-ahead log in the RunStore: save `idempotencyPhase: 'executing'` before the side effect, `'done'` + `executionResult` after. On re-dispatch `(runId, stepIndex)`: `done` → rebuild success outcome without re-running or re-logging; `executing` → throw `StepStateError`. `checkIdempotency()` runs before `doExecute()`; the `executing` marker is set in the `beforeCall` thunk passed to `AgentWithLog` (after `createPending`) so a log-creation failure leaves no orphan marker. Non-mutating steps don't override it (replay is safe).
- **Fetched steps must execute** — any step from `getAvailableRuns()` must run; silently dropping one breaks the orchestrator contract. The only allowed pre-filter is `inFlightRuns` dedup (keyed by `runId`, not step — a chain advances `stepId`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
type Step,
type StepUser,
} from '../types/validated/execution';
import { stepTypeToOutcomeType } from '../types/validated/step-outcome';
import {
ErrorKindSchema,
ErrorSourceStepIndexSchema,
stepTypeToOutcomeType,
} from '../types/validated/step-outcome';

function toRecordStatus(ctxStatus: unknown): RecordStepOutcome['status'] {
if (ctxStatus === 'error') return 'error';
Expand All @@ -44,10 +48,17 @@ function toStepOutcome(s: ServerStepHistory): StepOutcome {
const outcomeType = stepTypeToOutcomeType(stepDef.type);
const ctx = (s.context ?? {}) as Record<string, unknown>;

// A value the executor didn't write (legacy frontend, or a newer executor's vocabulary) is dropped
// rather than passed on: AvailableStepExecutionSchema.parse below would fail the whole run.
const parsedErrorKind = ErrorKindSchema.safeParse(ctx.errorKind);
const parsedSourceStepIndex = ErrorSourceStepIndexSchema.safeParse(ctx.errorSourceStepIndex);

const baseFromCtx = {
stepId: s.stepName,
stepIndex: s.stepIndex,
error: typeof ctx.error === 'string' ? ctx.error : undefined,
...(parsedErrorKind.success && { errorKind: parsedErrorKind.data }),
...(parsedSourceStepIndex.success && { errorSourceStepIndex: parsedSourceStepIndex.data }),
};

if (outcomeType === 'condition') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export default function toUpdateStepRequest(
): ServerUpdateStepRequest {
const context: Record<string, unknown> = { status: outcome.status };
if (outcome.error !== undefined) context.error = outcome.error;
if (outcome.errorKind !== undefined) context.errorKind = outcome.errorKind;

// Index 0 is a real step, so this cannot be a truthiness check.
if (outcome.errorSourceStepIndex !== undefined) {
context.errorSourceStepIndex = outcome.errorSourceStepIndex;
}

if (outcome.type === 'condition' && outcome.selectedOption !== undefined) {
context.selectedOption = outcome.selectedOption;
Expand Down
60 changes: 43 additions & 17 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file */
import type { MalformedRunInfo } from './ports/workflow-port';
import type { RecordId } from './types/validated/collection';
import type { AwaitingInputReason } from './types/validated/step-outcome';
import type { AwaitingInputReason, ErrorKind } from './types/validated/step-outcome';
import type { z } from 'zod';

export function causeMessage(error: unknown): string | undefined {
Expand Down Expand Up @@ -30,10 +30,19 @@ export abstract class WorkflowExecutorError extends Error {
readonly userMessage: string;
cause?: unknown;

// The kind of failure, declared once by each family below via defaultErrorKind. The throw site
// overrides it only where the same error can be either kind depending on why it was raised.
errorKind?: ErrorKind;
static readonly defaultErrorKind?: ErrorKind;

// Set when the error is about a different step than the one being executed.
errorSourceStepIndex?: number;

constructor(message: string, userMessage?: string) {
super(message);
this.name = this.constructor.name;
this.userMessage = userMessage ?? message;
this.errorKind = (this.constructor as typeof WorkflowExecutorError).defaultErrorKind;
}
}

Expand All @@ -46,6 +55,16 @@ export abstract class NotFoundError extends WorkflowExecutorError {}
export abstract class AccessDeniedError extends WorkflowExecutorError {}
export abstract class UnavailableError extends WorkflowExecutorError {}

// One abstract per classified kind: the family declares it once and a new member joins by extending
// it. An error extending neither stays unclassified, which is what preserves today's framing.
export abstract class WorkflowConfigurationError extends WorkflowExecutorError {
static override readonly defaultErrorKind: ErrorKind = 'configuration';
}

export abstract class WorkflowOperatorError extends WorkflowExecutorError {
static override readonly defaultErrorKind: ErrorKind = 'operator';
}

export class MissingToolCallError extends WorkflowExecutorError {
constructor() {
super(
Expand All @@ -67,7 +86,7 @@ export class MalformedToolCallError extends WorkflowExecutorError {
}
}

export class RecordNotFoundError extends WorkflowExecutorError {
export class RecordNotFoundError extends WorkflowOperatorError {
constructor(collectionName: string, recordId: RecordId) {
super(
`Record not found: collection "${collectionName}", id "${recordId.join('|')}"`,
Expand All @@ -76,13 +95,13 @@ export class RecordNotFoundError extends WorkflowExecutorError {
}
}

export class NoRecordsError extends WorkflowExecutorError {
export class NoRecordsError extends WorkflowOperatorError {
constructor() {
super('No records available');
}
}

export class NoReadableFieldsError extends WorkflowExecutorError {
export class NoReadableFieldsError extends WorkflowConfigurationError {
constructor(collectionName: string) {
super(
`No readable fields on record from collection "${collectionName}"`,
Expand All @@ -100,7 +119,7 @@ export class NoResolvedFieldsError extends WorkflowExecutorError {
}
}

export class NoWritableFieldsError extends WorkflowExecutorError {
export class NoWritableFieldsError extends WorkflowConfigurationError {
constructor(collectionName: string) {
super(
`No writable fields on record from collection "${collectionName}"`,
Expand All @@ -109,7 +128,7 @@ export class NoWritableFieldsError extends WorkflowExecutorError {
}
}

export class NoActionsError extends WorkflowExecutorError {
export class NoActionsError extends WorkflowConfigurationError {
constructor(collectionName: string) {
super(
`No actions available on collection "${collectionName}"`,
Expand All @@ -130,7 +149,7 @@ export class UnsupportedActionFormError extends WorkflowExecutorError {
// The action submission was rejected by the agent's server-side validation (bad/missing values),
// NOT an infra failure. Full AI treats this as a fallback-to-AI-assisted reason
// so a human can fix the values and resubmit.
export class ActionFormValidationError extends WorkflowExecutorError {
export class ActionFormValidationError extends WorkflowOperatorError {
constructor(actionName: string, cause?: unknown) {
super(
`Action "${actionName}" rejected the submitted form values`,
Expand All @@ -144,7 +163,7 @@ export class ActionFormValidationError extends WorkflowExecutorError {
// CustomActionRequiresApprovalError. Distinct from a plain permission 403 — Full AI
// falls back to AI-assisted so the native front handles the approval flow. The executor
// MUST NOT self-sign an approval request.
export class ActionRequiresApprovalError extends WorkflowExecutorError {
export class ActionRequiresApprovalError extends WorkflowOperatorError {
readonly roleIdsAllowedToApprove?: number[];

constructor(actionName: string, roleIdsAllowedToApprove?: number[]) {
Expand Down Expand Up @@ -177,7 +196,7 @@ export class RunStorePortError extends UnavailableError {
}
}

export class NoRelationshipFieldsError extends WorkflowExecutorError {
export class NoRelationshipFieldsError extends WorkflowConfigurationError {
constructor(collectionName: string) {
super(
`No relationship fields on record from collection "${collectionName}"`,
Expand All @@ -186,7 +205,7 @@ export class NoRelationshipFieldsError extends WorkflowExecutorError {
}
}

export class RelatedRecordNotFoundError extends WorkflowExecutorError {
export class RelatedRecordNotFoundError extends WorkflowOperatorError {
constructor(collectionName: string, relationName: string) {
super(
`No related record found for relation "${relationName}" on collection "${collectionName}"`,
Expand All @@ -201,13 +220,13 @@ export class InvalidAIResponseError extends WorkflowExecutorError {
}
}

export class InvalidAiRequestError extends WorkflowExecutorError {
export class InvalidAiRequestError extends WorkflowConfigurationError {
constructor(message: string) {
super(message, 'Step configuration error — please contact your administrator.');
}
}

export class RelationNotFoundError extends WorkflowExecutorError {
export class RelationNotFoundError extends WorkflowConfigurationError {
constructor(name: string, collectionName: string) {
super(
`Relation "${name}" not found in collection "${collectionName}"`,
Expand All @@ -216,7 +235,7 @@ export class RelationNotFoundError extends WorkflowExecutorError {
}
}

export class FieldNotFoundError extends WorkflowExecutorError {
export class FieldNotFoundError extends WorkflowConfigurationError {
constructor(name: string, collectionName: string) {
super(
`Field "${name}" not found in collection "${collectionName}"`,
Expand All @@ -225,7 +244,7 @@ export class FieldNotFoundError extends WorkflowExecutorError {
}
}

export class FieldTypeMissingError extends WorkflowExecutorError {
export class FieldTypeMissingError extends WorkflowConfigurationError {
constructor(name: string, collectionName: string) {
super(
`Field "${name}" in collection "${collectionName}" has no column type`,
Expand All @@ -235,7 +254,7 @@ export class FieldTypeMissingError extends WorkflowExecutorError {
}
}

export class ActionNotFoundError extends WorkflowExecutorError {
export class ActionNotFoundError extends WorkflowConfigurationError {
constructor(name: string, collectionName: string) {
super(
`Action "${name}" not found in collection "${collectionName}"`,
Expand Down Expand Up @@ -485,7 +504,7 @@ export class InvalidPendingDataError extends WorkflowExecutorError {
}
}

export class InvalidPreRecordedArgsError extends WorkflowExecutorError {
export class InvalidPreRecordedArgsError extends WorkflowConfigurationError {
constructor(detail: string) {
super(`Invalid pre-recorded args: ${detail}`, 'The pre-configured step parameters are invalid');
}
Expand All @@ -494,13 +513,20 @@ export class InvalidPreRecordedArgsError extends WorkflowExecutorError {
// 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).
// The kind comes from the throw site: only there is it known whether the operator had a record to
// pick, which is what decides who can act on it.
export class SourceRecordMissingError extends WorkflowExecutorError {
constructor(sourceTitle?: string) {
constructor(
sourceTitle?: string,
options: { errorKind?: ErrorKind; errorSourceStepIndex?: number } = {},
) {
const from = sourceTitle ? `"${sourceTitle}"` : 'its source step';
super(
`Source step ${from} loaded no record`,
`This step uses ${from} as its source, but that step didn't load any record.`,
);
this.errorKind = options.errorKind ?? this.errorKind;
this.errorSourceStepIndex = options.errorSourceStepIndex;
}
}

Expand Down
21 changes: 18 additions & 3 deletions packages/workflow-executor/src/executors/base-step-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
import type { ConfirmableStepExecutionData, StepExecutionData } from '../types/step-execution-data';
import type { Step } from '../types/validated/execution';
import type { StepDefinition } from '../types/validated/step-definition';
import type { StepStatus } from '../types/validated/step-outcome';
import type { ErrorKind, StepStatus } from '../types/validated/step-outcome';
import type {
BaseMessage,
DynamicStructuredTool,
Expand Down Expand Up @@ -75,7 +75,7 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
timeoutS: this.context.stepTimeoutS,
});

return this.buildOutcomeResult({ status: 'error', error: error.userMessage });
return this.buildErrorOutcome(error);
}

if (error instanceof WorkflowExecutorError) {
Expand All @@ -85,7 +85,7 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
stack: error.cause instanceof Error ? error.cause.stack : undefined,
});

return this.buildOutcomeResult({ status: 'error', error: error.userMessage });
return this.buildErrorOutcome(error);
}

const { cause: errorCause } = error as { cause?: unknown };
Expand All @@ -103,6 +103,19 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
}
}

// Every classified error reaches the outcome through here, so a new catch branch cannot silently
// drop the classification.
private buildErrorOutcome(error: WorkflowExecutorError): StepExecutionResult {
return this.buildOutcomeResult({
status: 'error',
error: error.userMessage,
...(error.errorKind !== undefined && { errorKind: error.errorKind }),
...(error.errorSourceStepIndex !== undefined && {
errorSourceStepIndex: error.errorSourceStepIndex,
}),
});
}

protected abstract doExecute(): Promise<StepExecutionResult>;

protected checkIdempotency(): Promise<StepExecutionResult | null> {
Expand Down Expand Up @@ -146,6 +159,8 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St
protected abstract buildOutcomeResult(outcome: {
status: StepStatus;
error?: string;
errorKind?: ErrorKind;
Comment thread
hercemer42 marked this conversation as resolved.
errorSourceStepIndex?: number;
}): StepExecutionResult;

protected async findPendingExecution<TExec extends ConfirmableStepExecutionData>(
Expand Down
Loading
Loading