Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/workflow-executor/CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
UnsupportedStepTypeError,
} from '../errors';
import {
type ApprovalRequestReview,
ApprovalRequestStatusSchema,
type AvailableStepExecution,
AvailableStepExecutionSchema,
type CallScope,
Expand All @@ -33,6 +35,7 @@ import {
} from '../types/validated/execution';
import { WORKFLOW_START_STEP_ID } from '../types/validated/step-definition';
import {
ApprovalOutcomeSchema,
ErrorKindSchema,
ErrorSourceStepIndexSchema,
stepTypeToOutcomeType,
Expand Down Expand Up @@ -90,7 +93,37 @@ function toStepOutcome(s: ServerStepHistory): StepOutcome {
return { type: 'mcp', ...baseFromCtx, status } satisfies McpStepOutcome;
}

return { type: 'record', ...baseFromCtx, status } satisfies RecordStepOutcome;
const selectedOption = ApprovalOutcomeSchema.safeParse(ctx.selectedOption);

return {
type: 'record',
...baseFromCtx,
status,
...(selectedOption.success && { selectedOption: selectedOption.data }),
} satisfies RecordStepOutcome;
}

// The server sends the row id as a number; the executor keeps the JSON:API string it was handed.
// A status outside the known ones is dropped, so the replay reads the request as unknown.
function toApprovalRequestReviews(context: unknown): ApprovalRequestReview[] | undefined {
const refs = (context as { approvalRequests?: unknown } | null | undefined)?.approvalRequests;

if (!Array.isArray(refs)) return undefined;

return refs.flatMap((ref): ApprovalRequestReview[] => {
const { id, status, approverId } = (ref ?? {}) as Record<string, unknown>;

if (typeof id !== 'string' && typeof id !== 'number') return [];

const parsedStatus = ApprovalRequestStatusSchema.safeParse(status);
const review: ApprovalRequestReview = { id: String(id) };

if (parsedStatus.success) review.status = parsedStatus.data;
if (typeof approverId === 'number') review.approverId = approverId;
if (approverId === null) review.approverId = null;

return [review];
});
}

function tryMapStep(s: ServerStepHistory): Step | null {
Expand Down Expand Up @@ -249,6 +282,7 @@ export default function toAvailableStepExecution(
if (!pending || pending.done) return null;

const callScope = toCallScope(run.workflowHistory, pending);
const approvalRequests = toApprovalRequestReviews(pending.context);

const result = {
runId: String(run.id),
Expand All @@ -274,6 +308,7 @@ export default function toAvailableStepExecution(
// user, since that one follows the browser.
timezone: toProjectTimezone(run.timezone),
...(callScope && { callScope }),
...(approvalRequests && { approvalRequests }),
};

// Defense against mapper bugs: zod asserts the shape we produce is what the domain expects,
Expand Down
3 changes: 2 additions & 1 deletion packages/workflow-executor/src/adapters/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface ServerWorkflowTaskBase extends ServerWorkflowStepBase {
taskType: ServerTaskTypeEnum;
isSubTask?: boolean;
prompt: string | null;
outgoing: [ServerWorkflowTransition];
outgoing: [ServerWorkflowTransition, ...ServerWorkflowTransition[]];
}

export interface ServerWorkflowTaskGuideline extends ServerWorkflowTaskBase {
Expand Down Expand Up @@ -88,6 +88,7 @@ interface ServerWorkflowTaskTriggerAction extends ServerWorkflowTaskBase {
| ServerStepExecutionTypeEnum.FullyAutomated
| ServerStepExecutionTypeEnum.AutomatedWithConfirmation;
preRecordedArgs?: { selectedRecordStepId?: string; actionName?: string };
approvalOutcomes?: boolean | null;
}

interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function mapTask(task: ServerWorkflowTask): StepDefinition {
...base,
type: StepType.TriggerAction,
preRecordedArgs: task.preRecordedArgs,
approvalOutcomes: task.approvalOutcomes,
});
case ServerTaskTypeEnum.LoadRelatedRecord:
return parseStepDefinition(LoadRelatedRecordStepDefinitionSchema, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,15 @@ export default function toUpdateStepRequest(
context.selectedOption = outcome.selectedOption;
}

if (outcome.type === 'record' && outcome.approvalRequest !== undefined) {
context.approvalRequest = outcome.approvalRequest;
if (outcome.type === 'record') {
const { approvalRequest, approvalRequests, awaitingInputReason, selectedOption, approverId } =
outcome;

if (approvalRequest !== undefined) context.approvalRequest = approvalRequest;
if (approvalRequests !== undefined) context.approvalRequests = approvalRequests;
if (awaitingInputReason !== undefined) context.awaitingInputReason = awaitingInputReason;
if (selectedOption !== undefined) context.selectedOption = selectedOption;
if (approverId !== undefined) context.approverId = approverId;
}

if (outcome.type === 'mcp' && outcome.awaitingInputReason !== undefined) {
Expand Down
39 changes: 39 additions & 0 deletions packages/workflow-executor/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,45 @@ export class ActionRequiresApprovalError extends WorkflowOperatorError {
}
}

// Finishing a step with branches by hand reports no branch until the native form carries one
// (PRD-2013), so the Full AI fallback to that form is refused rather than left to break the run.
export class ApprovalBranchesNeedFullAiError extends WorkflowOperatorError {
constructor(actionName: string, reason: string) {
super(
`Action "${actionName}" could not run automatically (${reason}), and its step has approval branches`,
'This step has Approved and Rejected branches, which only run automatically for now. Complete the action configuration so the AI can submit it, then revise the step.',
);
}
}

// The request was filed, but without its id the step can neither wait for it nor learn its review.
export class ApprovalRequestUntrackedError extends WorkflowExecutorError {
constructor(actionName: string) {
super(
`Action "${actionName}" was submitted for approval, but the approval request id was not returned`,
'This action was submitted for approval, but the workflow cannot follow its review. Check the approval requests, then revise the step.',
);
}
}

export class ApprovalRequestFailedError extends WorkflowOperatorError {
constructor(approvalRequestId: string) {
super(
`Approval request ${approvalRequestId} was approved but the action failed when it ran`,
'The action was approved, but it failed when it ran. Check the approval request, then revise the step.',
);
}
}

export class ApprovalRequestGoneError extends WorkflowExecutorError {
constructor(approvalRequestId: string) {
super(
`Approval request ${approvalRequestId} no longer exists`,
'The approval request of this step no longer exists. Revise the step to submit the action again.',
);
}
}

// Approval-gated action whose approval request couldn't be filed — neither executed nor approved.
export class ApprovalRequestCreationError extends WorkflowExecutorError {
constructor(actionName: string, cause?: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export default class StepExecutorFactory {
user: step.user,
timezone: step.timezone,
callScope: step.callScope,
approvalRequests: step.approvalRequests,
model: cfg.aiModelPort.getModel({
aiConfigName: step.stepDefinition.aiConfigName,
userId: step.user.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export default class StepExecutionFormatters {
const action = execution.executionParams?.displayName ?? 'the action';
const submitter = executionResult.submittedBy === 'ai' ? 'AI' : 'the user';

if (executionResult.approvalOutcome === 'approved') {
return ` Submitted action "${action}" for approval (by ${submitter}). It was APPROVED and run by the reviewer — its result is not available here.`;
}

if (executionResult.approvalOutcome === 'rejected') {
return ` Submitted action "${action}" for approval (by ${submitter}). It was REJECTED by the reviewer and has NOT been executed.`;
}

if (executionResult.submissionOutcome === 'pending-approval') {
return ` Submitted action "${action}" for approval (by ${submitter}). It is AWAITING APPROVAL and has NOT been executed — no result is available yet.`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ import type {
} from '../types/step-execution-data';
import type { ActionSchema, CollectionSchema, RecordRef } from '../types/validated/collection';
import type { TriggerActionStepDefinition } from '../types/validated/step-definition';
import type { ErrorKind, RecordStepStatus } from '../types/validated/step-outcome';
import type {
ApprovalOutcome,
ApprovalRequestRef,
AwaitingInputReason,
ErrorKind,
RecordStepStatus,
} from '../types/validated/step-outcome';

import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy';
import { isDeepStrictEqual } from 'util';
Expand All @@ -18,6 +24,10 @@ import {
ActionFormValidationError,
ActionNotFoundError,
ActionRequiresApprovalError,
ApprovalBranchesNeedFullAiError,
ApprovalRequestFailedError,
ApprovalRequestGoneError,
ApprovalRequestUntrackedError,
NoActionsError,
PinnedArgNotFoundError,
StepStateError,
Expand Down Expand Up @@ -73,17 +83,75 @@ interface ActionTarget extends ActionRef {
}

export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<TriggerActionStepDefinition> {
// approvalRequest lives here, off the shared record builder; super spreads it onto the outcome.
// The approval fields live here, off the shared record builder; super spreads them onto the outcome.
protected override buildOutcomeResult(outcome: {
status: RecordStepStatus;
error?: string;
errorKind?: ErrorKind;
errorSourceStepIndex?: number;
approvalRequest?: { id: string };
awaitingInputReason?: AwaitingInputReason;
approvalRequests?: ApprovalRequestRef[];
selectedOption?: ApprovalOutcome;
approverId?: number | null;
}): StepExecutionResult {
return super.buildOutcomeResult(outcome);
}

private get hasApprovalOutcomes(): boolean {
return this.context.stepDefinition.approvalOutcomes === true;
}

private buildApprovalPause(approvalRequest: { id: string }): StepExecutionResult {
return this.buildOutcomeResult({
status: 'awaiting-input',
awaitingInputReason: 'pending-approval',
approvalRequests: [{ id: approvalRequest.id }],
});
}

private buildExecutedResult(): StepExecutionResult {
Comment thread
Scra3 marked this conversation as resolved.
Resolved
return this.buildOutcomeResult({
status: 'success',
...(this.hasApprovalOutcomes && { selectedOption: 'approved' as const }),
});
}

// The server hands back the review of the request the step paused on. Until it is closed the
// step pauses again; once closed it takes the branch the reviewer chose.
private async replayReviewedApproval(
existing: TriggerRecordActionStepExecutionData,
approvalRequest: { id: string },
): Promise<StepExecutionResult> {
const review = this.context.approvalRequests?.find(({ id }) => id === approvalRequest.id);

if (!review) return this.buildApprovalPause(approvalRequest);

if (review.status === 'approved' || review.status === 'rejected') {
await this.context.runStore.saveStepExecution(this.context.runId, {
...existing,
executionResult: {
...(existing.executionResult as Exclude<
TriggerRecordActionStepExecutionData['executionResult'],
{ skipped: true }
>),
approvalOutcome: review.status,
},
});

return this.buildOutcomeResult({
status: 'success',
selectedOption: review.status,
approverId: review.approverId ?? null,
});
}

if (review.status === 'failed') throw new ApprovalRequestFailedError(approvalRequest.id);
if (review.status === undefined) throw new ApprovalRequestGoneError(approvalRequest.id);

return this.buildApprovalPause(approvalRequest);
}

protected override async checkIdempotency(): Promise<StepExecutionResult | null> {
const existing = await this.findPendingExecution<TriggerRecordActionStepExecutionData>(
'trigger-action',
Expand All @@ -95,6 +163,18 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
result && !('skipped' in result) && result.submissionOutcome === 'pending-approval';
const approvalRequest = isPendingApproval ? result.approvalRequest : undefined;

if (isPendingApproval && this.hasApprovalOutcomes) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5.5 (claude-opus-5-5): Preferential: a paused approval step keeps a done record and resumes through the idempotency replay, and no ADR records that choice.

Code

Paused runs persist a done plus pending-approval record, a reader asks why a waiting step is done, and a pause phase was the alternative.

A way out: record the decision with /adr in docs/adr/.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Opus 5.5 (claude-opus-5-5): No ADR, the paused done record fails the hard-to-reverse leg of the gate.

Why

A later pause phase can still read today's done plus pending-approval records the way checkIdempotency does, so switching costs one read path, not a migration.
The record lives in the client's RunStore and never crosses the orchestrator contract.

if (!approvalRequest) {
throw new ApprovalRequestUntrackedError(existing.executionParams?.name ?? 'unknown');
}

return this.replayReviewedApproval(existing, approvalRequest);
}

if (!isPendingApproval && result && !('skipped' in result)) {
return this.buildExecutedResult();
}

if (isPendingApproval && !approvalRequest) {
this.context.logger(
'Warn',
Expand Down Expand Up @@ -275,6 +355,10 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
form?: { fields: ActionFormField[]; aiFilledValues: AiFilledFormValue[] },
fullAiFallback?: FullAiFallback,
): Promise<StepExecutionResult> {
if (fullAiFallback && this.hasApprovalOutcomes) {
throw new ApprovalBranchesNeedFullAiError(target.name, fullAiFallback.reason);
}

await this.context.runStore.saveStepExecution(this.context.runId, {
type: 'trigger-action',
stepIndex: this.context.stepIndex,
Expand Down Expand Up @@ -536,6 +620,12 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
idempotencyPhase: 'done',
});

if (this.hasApprovalOutcomes) {
if (!outcome.approvalRequest) throw new ApprovalRequestUntrackedError(name);

return this.buildApprovalPause(outcome.approvalRequest);
}

return this.buildOutcomeResult({
status: 'success',
...(outcome.approvalRequest && { approvalRequest: outcome.approvalRequest }),
Expand All @@ -556,7 +646,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor<
idempotencyPhase: 'done',
});

return this.buildOutcomeResult({ status: 'success' });
return this.buildExecutedResult();
}

/**
Expand Down
9 changes: 8 additions & 1 deletion packages/workflow-executor/src/types/execution-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import type { Logger } from '../ports/logger-port';
import type { RunStore } from '../ports/run-store';
import type SchemaResolver from '../schema-resolver';
import type { RecordRef } from './validated/collection';
import type { AvailableStepExecution, CallScope, Step, StepUser } from './validated/execution';
import type {
ApprovalRequestReview,
AvailableStepExecution,
CallScope,
Step,
StepUser,
} from './validated/execution';
import type { StepDefinition } from './validated/step-definition';
import type { StepOutcome } from './validated/step-outcome';
import type { BaseChatModel } from '@forestadmin/ai-proxy';
Expand Down Expand Up @@ -39,6 +45,7 @@ export interface ExecutionContext<TStep extends StepDefinition = StepDefinition>
readonly logger: Logger;
// Set only while the step runs inside a Sub-workflow call.
readonly callScope?: CallScope;
readonly approvalRequests?: ReadonlyArray<ApprovalRequestReview>;
readonly incomingPendingData?: unknown;
readonly stepTimeoutS?: number;
readonly aiInvokeTimeoutS?: number;
Expand Down
3 changes: 3 additions & 0 deletions packages/workflow-executor/src/types/step-execution-data.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ActionFormField } from '../ports/agent-port';
import type { RecordId, RecordRef } from './validated/collection';
import type { ApprovalOutcome } from './validated/step-outcome';
import type {
LoadRelatedRecordConfirmation,
McpConfirmation,
Expand Down Expand Up @@ -161,6 +162,8 @@ export interface TriggerRecordActionStepExecutionData
// (human via the native front). Absent for formless/legacy flows.
submittedBy?: 'ai' | 'user';
approvalRequest?: { id: string };
// Set once the request is reviewed, on a step saved with approval outcomes.
approvalOutcome?: ApprovalOutcome;
}
| { skipped: true };
pendingData?: TriggerActionPendingData;
Expand Down
Loading
Loading