From 49ad16daf45c2a22f2efe3901d126ceed4e35582 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 21 May 2026 12:13:57 +0200 Subject: [PATCH 1/7] fix(workflow): replace automatedExecution with executionType --- .../adapters/run-to-available-step-mapper.ts | 2 +- .../src/adapters/server-types.ts | 122 ++++++++++++------ .../src/adapters/step-definition-mapper.ts | 13 +- .../src/executors/base-step-executor.ts | 6 +- .../src/executors/condition-step-executor.ts | 13 +- .../load-related-record-step-executor.ts | 9 +- .../src/executors/mcp-step-executor.ts | 5 +- .../trigger-record-action-step-executor.ts | 14 +- .../executors/update-record-step-executor.ts | 7 +- .../src/types/validated/step-definition.ts | 17 +-- .../src/types/validated/step-outcome.ts | 2 +- 11 files changed, 135 insertions(+), 75 deletions(-) diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index f86a074c45..a8a45a3b21 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -49,7 +49,7 @@ function toStepOutcome(s: ServerStepHistory): StepOutcome { }; if (outcomeType === 'condition') { - const status: ConditionStepOutcome['status'] = ctx.status === 'error' ? 'error' : 'success'; + const status: ConditionStepOutcome['status'] = toRecordStatus(ctx.status); const selectedOption = typeof ctx.selectedOption === 'string' ? ctx.selectedOption : undefined; return { diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 358456aa10..4330ff652d 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -13,61 +13,103 @@ export interface ServerWorkflowTransition { answer?: string; } -export type ServerTaskType = - | 'guideline' - | 'trigger-action' - | 'get-data' - | 'update-data' - | 'load-related-record' - | 'mcp-server'; - -export interface ServerWorkflowTask { - type: 'task'; - taskType: ServerTaskType; - isSubTask?: boolean; - title: string; - prompt: string; - allowedTools?: string[]; - mcpServerId?: string; - automaticExecution?: boolean; - automaticCompletion?: boolean; - outgoing: ServerWorkflowTransition; +export enum ServerStepTypeEnum { + Task = 'task', + Condition = 'condition', + End = 'end', + Escalation = 'escalation', + StartSubWorkflow = 'start-sub-workflow', + CloseSubWorkflow = 'close-sub-workflow', } -export interface ServerWorkflowCondition { - type: 'condition'; - title: string; - prompt: string; - outgoing: ServerWorkflowTransition[]; - automaticExecution?: boolean; +export enum ServerTaskTypeEnum { + Guideline = 'guideline', + TriggerAction = 'trigger-action', + GetData = 'get-data', + UpdateData = 'update-data', + LoadRelatedRecord = 'load-related-record', + McpServer = 'mcp-server', +} + +export enum ServerStepExecutionTypeEnum { + Manual = 'manual', + AutomatedWithConfirmation = 'automated-with-confirmation', + FullyAutomated = 'fully-automated', } -export interface ServerWorkflowEnd { - type: 'end'; +interface ServerWorkflowStepBase { + type: ServerStepTypeEnum; title: string; prompt?: string; + executionType: ServerStepExecutionTypeEnum; + automaticCompletion: boolean; + outgoing: ServerWorkflowTransition[]; } -export interface ServerWorkflowEscalation { - type: 'escalation'; - title: string; +export interface ServerWorkflowTaskBase extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.Task; + taskType: ServerTaskTypeEnum; + isSubTask?: boolean; prompt: string; - outgoing: ServerWorkflowTransition; - inboxId: string | null; + outgoing: [ServerWorkflowTransition]; } -export interface ServerStartSubWorkflow { - type: 'start-sub-workflow'; - title: string; +export interface ServerWorkflowTaskGuideline extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.Guideline; + completionType: 'simple' | 'user-input'; + inputType?: 'free-text'; + automaticCompletion: false; +} + +export interface ServerWorkflowTaskSimple extends ServerWorkflowTaskBase { + taskType: + | ServerTaskTypeEnum.GetData + | ServerTaskTypeEnum.UpdateData + | ServerTaskTypeEnum.TriggerAction + | ServerTaskTypeEnum.LoadRelatedRecord; +} + +export interface ServerWorkflowTaskMcpServer extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.McpServer; + mcpServerId: string; +} + +export type ServerWorkflowTask = + | ServerWorkflowTaskGuideline + | ServerWorkflowTaskSimple + | ServerWorkflowTaskMcpServer; + +export interface ServerWorkflowEnd extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.End; + executionType: ServerStepExecutionTypeEnum.Manual; + automaticCompletion: false; + outgoing: []; +} + +export interface ServerWorkflowCondition extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.Condition; + prompt: string; + automaticCompletion: false; +} + +export interface ServerWorkflowEscalation extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.Escalation; prompt: string; - outgoing: ServerWorkflowTransition; + outgoing: [ServerWorkflowTransition]; + inboxId: string | null; +} + +export interface ServerStartSubWorkflow extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.StartSubWorkflow; + executionType: ServerStepExecutionTypeEnum.Manual; + outgoing: [ServerWorkflowTransition]; workflowId: string; } -export interface ServerCloseSubWorkflow { - type: 'close-sub-workflow'; - title?: string; - outgoing: ServerWorkflowTransition; +export interface ServerCloseSubWorkflow extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.CloseSubWorkflow; + executionType: ServerStepExecutionTypeEnum.Manual; + outgoing: [ServerWorkflowTransition]; parentWorkflowId: string | null; } diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index aeeab1cd6c..45bc99ff36 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -1,5 +1,6 @@ import type { - ServerTaskType, + ServerStepExecutionTypeEnum, + ServerTaskTypeEnum, ServerWorkflowCondition, ServerWorkflowStep, ServerWorkflowTask, @@ -9,7 +10,7 @@ import type { ConditionStepDefinition, StepDefinition } from '../types/validated import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../errors'; import { StepType } from '../types/validated/step-definition'; -const TASK_TYPE_TO_STEP_TYPE: Record = { +const TASK_TYPE_TO_STEP_TYPE: Record = { 'get-data': StepType.ReadRecord, 'update-data': StepType.UpdateRecord, 'trigger-action': StepType.TriggerAction, @@ -25,15 +26,17 @@ function mapTask(task: ServerWorkflowTask): StepDefinition { throw new InvalidStepDefinitionError(`Unknown taskType: "${task.taskType}"`); } - const base: { prompt: string; automaticExecution?: boolean } = { prompt: task.prompt }; - if (task.automaticExecution !== undefined) base.automaticExecution = task.automaticExecution; + const base: { prompt: string; executionType?: ServerStepExecutionTypeEnum } = { + prompt: task.prompt, + }; + if (task.executionType !== undefined) base.executionType = task.executionType; switch (stepType) { case StepType.Mcp: return { ...base, type: StepType.Mcp, - ...(task.mcpServerId !== undefined && { mcpServerId: task.mcpServerId }), + ...('mcpServerId' in task && { mcpServerId: task.mcpServerId }), }; case StepType.Guidance: return { ...base, type: StepType.Guidance }; diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 8f6de37859..742cfe67f5 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -128,8 +128,10 @@ export default abstract class BaseStepExecutor { protected buildOutcomeResult(outcome: { - status: BaseStepStatus; + status: ConditionStepOutcome['status']; error?: string; selectedOption?: string; }): StepExecutionResult { @@ -61,6 +62,14 @@ export default class ConditionStepExecutor extends BaseStepExecutor { const { stepDefinition: step, incomingPendingData } = this.context; + // Manual mode: the user picks the option from the frontend. Wait for their input + // without ever calling the AI. + const isManual = step.executionType === ServerStepExecutionTypeEnum.Manual; + + if (isManual && incomingPendingData === undefined) { + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + const { selectedOption, reasoning } = incomingPendingData !== undefined ? this.readUserChoice(step, incomingPendingData) diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 23d42be8c5..9042b843b3 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -7,6 +7,7 @@ import type { LoadRelatedRecordStepDefinition } from '../types/validated/step-de import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { InvalidAIResponseError, InvalidPreRecordedArgsError, @@ -78,12 +79,12 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50); diff --git a/packages/workflow-executor/src/executors/mcp-step-executor.ts b/packages/workflow-executor/src/executors/mcp-step-executor.ts index fca94387a5..5f6850b49e 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -8,6 +8,7 @@ import type { RemoteTool } from '@forestadmin/ai-proxy'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { McpToolInvocationError, McpToolNotFoundError, @@ -87,12 +88,12 @@ export default class McpStepExecutor extends BaseStepExecutor if (!selectedTool) throw new McpToolNotFoundError(toolName); const target: McpToolCall = { name: toolName, sourceId: selectedTool.sourceId, input: args }; - if (this.context.stepDefinition.automaticExecution) { + if (this.context.stepDefinition.executionType === ServerStepExecutionTypeEnum.FullyAutomated) { // Branch B -- direct execution return this.executeToolAndPersist(target); } - // Branch C -- Awaiting confirmation + // Branch C -- Awaiting confirmation (also covers Manual fallback) await this.context.runStore.saveStepExecution(this.context.runId, { type: 'mcp', stepIndex: this.context.stepIndex, diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index 4b75633601..64b22bf256 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -7,6 +7,7 @@ import type { TriggerActionStepDefinition } from '../types/validated/step-defini import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { ActionNotFoundError, NoActionsError, @@ -29,9 +30,11 @@ interface ActionTarget extends ActionRef { export default class TriggerRecordActionStepExecutor extends RecordStepExecutor { protected override buildActivityLogArgs(): CreateActivityLogArgs | null { - // Skip when the frontend executes the action itself (non-automatic mode). + // Skip when the frontend executes the action itself (non fully-automated mode). // The front logs on its side via the standard agent activity flow. - if (this.context.stepDefinition.automaticExecution !== true) return null; + if (this.context.stepDefinition.executionType !== ServerStepExecutionTypeEnum.FullyAutomated) { + return null; + } return { renderingId: this.context.user.renderingId, @@ -111,10 +114,10 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< const name = this.resolveActionName(schema, args.actionName); const target: ActionTarget = { selectedRecordRef, displayName: args.actionName, name }; - // Branch B -- automaticExecution: executor runs the action itself, so it cannot + // Branch B -- fully automated: executor runs the action itself, so it cannot // handle forms (no UI to fill them). Reject form-bearing actions here. When the // frontend is in the loop (Branch C), it handles the form natively so no check. - if (step.automaticExecution) { + if (step.executionType === ServerStepExecutionTypeEnum.FullyAutomated) { const { hasForm } = await this.agentPort.getActionFormInfo( { collection: selectedRecordRef.collectionName, @@ -128,7 +131,8 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< return this.executeOnExecutor(target); } - // Branch C -- Awaiting confirmation (frontend executes the action, including forms) + // Branch C -- Awaiting confirmation (frontend executes the action, including forms). + // Also covers Manual fallback. await this.context.runStore.saveStepExecution(this.context.runId, { type: 'trigger-action', stepIndex: this.context.stepIndex, 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 f27b17783b..3f457576ac 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -7,6 +7,7 @@ import type { UpdateRecordStepDefinition } from '../types/validated/step-definit import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { FieldNotFoundError, InvalidPreRecordedArgsError, @@ -180,12 +181,12 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor; export const ReadRecordStepDefinitionSchema = z.object({ - ...baseRecordFields, + ...baseFields, type: z.literal(StepType.ReadRecord), preRecordedArgs: z .object({ @@ -43,7 +41,7 @@ export const ReadRecordStepDefinitionSchema = z.object({ export type ReadRecordStepDefinition = z.infer; export const UpdateRecordStepDefinitionSchema = z.object({ - ...baseRecordFields, + ...baseFields, type: z.literal(StepType.UpdateRecord), preRecordedArgs: z .object({ @@ -57,7 +55,7 @@ export const UpdateRecordStepDefinitionSchema = z.object({ export type UpdateRecordStepDefinition = z.infer; export const TriggerActionStepDefinitionSchema = z.object({ - ...baseRecordFields, + ...baseFields, type: z.literal(StepType.TriggerAction), preRecordedArgs: z .object({ @@ -70,7 +68,7 @@ export const TriggerActionStepDefinitionSchema = z.object({ export type TriggerActionStepDefinition = z.infer; export const LoadRelatedRecordStepDefinitionSchema = z.object({ - ...baseRecordFields, + ...baseFields, type: z.literal(StepType.LoadRelatedRecord), preRecordedArgs: z .object({ @@ -87,7 +85,6 @@ export const McpStepDefinitionSchema = z.object({ ...baseFields, type: z.literal(StepType.Mcp), mcpServerId: z.string().optional(), - automaticExecution: z.boolean().optional(), }); export type McpStepDefinition = z.infer; diff --git a/packages/workflow-executor/src/types/validated/step-outcome.ts b/packages/workflow-executor/src/types/validated/step-outcome.ts index 89ec606939..5eec75d719 100644 --- a/packages/workflow-executor/src/types/validated/step-outcome.ts +++ b/packages/workflow-executor/src/types/validated/step-outcome.ts @@ -29,7 +29,7 @@ export const ConditionStepOutcomeSchema = z .object({ ...baseOutcomeFields, type: z.literal('condition'), - status: BaseStepStatusSchema, + status: RecordStepStatusSchema, /** Present when status is 'success'. */ selectedOption: z.string().optional(), }) From e6767f1170aebb36dc35a898b0a27140276f27f0 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 21 May 2026 13:09:16 +0200 Subject: [PATCH 2/7] fix: tests --- .../src/adapters/step-definition-mapper.ts | 1 + .../src/build-workflow-executor.ts | 2 +- .../forest-server-workflow-port.test.ts | 91 +++++---- .../run-to-available-step-mapper.test.ts | 186 +++++++++++------- .../adapters/step-definition-mapper.test.ts | 139 ++++++++++--- .../executors/condition-step-executor.test.ts | 67 +++++++ .../load-related-record-step-executor.test.ts | 51 ++--- .../test/executors/mcp-step-executor.test.ts | 28 +-- ...rigger-record-action-step-executor.test.ts | 45 +++-- .../update-record-step-executor.test.ts | 41 ++-- 10 files changed, 434 insertions(+), 217 deletions(-) diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index 45bc99ff36..f7aee015b8 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -68,6 +68,7 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti type: StepType.Condition, prompt: condition.prompt, options, + ...(condition.executionType !== undefined && { executionType: condition.executionType }), }; } diff --git a/packages/workflow-executor/src/build-workflow-executor.ts b/packages/workflow-executor/src/build-workflow-executor.ts index f06b0abce0..7f422e43ff 100644 --- a/packages/workflow-executor/src/build-workflow-executor.ts +++ b/packages/workflow-executor/src/build-workflow-executor.ts @@ -19,7 +19,7 @@ import DatabaseStore from './stores/database-store'; import InMemoryStore from './stores/in-memory-store'; const DEFAULT_FOREST_SERVER_URL = 'https://api.forestadmin.com'; -const DEFAULT_POLLING_INTERVAL_MS = 5000; +const DEFAULT_POLLING_INTERVAL_MS = 30000; const DEFAULT_STEP_TIMEOUT_MS = 5 * 60_000; const FORCE_EXIT_DELAY_MS = 5000; diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index dc4c27f1f7..8c5a12cfd9 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -1,12 +1,50 @@ -import type { ServerHydratedWorkflowRun, ServerUserProfile } from '../../src/adapters/server-types'; +import type { + ServerHydratedWorkflowRun, + ServerUserProfile, + ServerWorkflowCondition, + ServerWorkflowEscalation, +} from '../../src/adapters/server-types'; import type { CollectionSchema } from '../../src/types/validated/collection'; import type { StepOutcome } from '../../src/types/validated/step-outcome'; import { ServerUtils } from '@forestadmin/forestadmin-client'; import ForestServerWorkflowPort from '../../src/adapters/forest-server-workflow-port'; +import { ServerStepExecutionTypeEnum, ServerStepTypeEnum } from '../../src/adapters/server-types'; import { MalformedRunError } from '../../src/errors'; +function makeConditionStepDef( + overrides: Partial = {}, +): ServerWorkflowCondition { + return { + type: ServerStepTypeEnum.Condition, + title: 'Decide', + prompt: 'pick one', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [ + { stepId: 'next-a', buttonText: 'A', answer: 'Yes' }, + { stepId: 'next-b', buttonText: 'B', answer: 'No' }, + ], + ...overrides, + }; +} + +function makeEscalationStepDef( + overrides: Partial = {}, +): ServerWorkflowEscalation { + return { + type: ServerStepTypeEnum.Escalation, + title: 'Escalate', + prompt: 'pick one', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [{ stepId: 'n', buttonText: null }], + inboxId: null, + ...overrides, + }; +} + jest.mock('@forestadmin/forestadmin-client', () => ({ ServerUtils: { query: jest.fn() }, })); @@ -29,15 +67,7 @@ function makeRun(overrides: Partial = {}): ServerHydr stepName: 'step-1', stepIndex: 0, done: false, - stepDefinition: { - type: 'condition', - title: 'Decide', - prompt: 'pick one', - outgoing: [ - { stepId: 'next-a', buttonText: 'A', answer: 'Yes' }, - { stepId: 'next-b', buttonText: 'B', answer: 'No' }, - ], - }, + stepDefinition: makeConditionStepDef(), }, ], createdAt: '2026-04-20T00:00:00.000Z', @@ -93,12 +123,11 @@ describe('ForestServerWorkflowPort', () => { stepName: 'step-1', stepIndex: 0, done: true, - stepDefinition: { - type: 'condition', + stepDefinition: makeConditionStepDef({ title: 'Done', prompt: '', outgoing: [{ stepId: 'next', buttonText: 'ok', answer: 'ok' }], - }, + }), }, ], }); @@ -143,23 +172,21 @@ describe('ForestServerWorkflowPort', () => { stepName: 'done-step', stepIndex: 0, done: true, - stepDefinition: { - type: 'condition', + stepDefinition: makeConditionStepDef({ title: 'x', prompt: 'x', outgoing: [{ stepId: 'n', buttonText: 'ok', answer: 'ok' }], - }, + }), }, { stepName: 'pending-step', stepIndex: 1, done: false, - stepDefinition: { - type: 'condition', + stepDefinition: makeConditionStepDef({ title: 'y', prompt: 'y', outgoing: [{ stepId: 'm', buttonText: 'ok', answer: 'ok' }], - }, + }), }, ], }); @@ -191,13 +218,7 @@ describe('ForestServerWorkflowPort', () => { stepName: 'esc-step', stepIndex: 0, done: false, - stepDefinition: { - type: 'escalation', - title: 'x', - prompt: 'x', - outgoing: { stepId: 'n', buttonText: null }, - inboxId: null, - }, + stepDefinition: makeEscalationStepDef({ title: 'x', prompt: 'x' }), }, ], }); @@ -270,15 +291,12 @@ describe('ForestServerWorkflowPort', () => { stepName: '', stepIndex: 0, done: false, - stepDefinition: { - type: 'condition', - title: 'Decide', - prompt: 'pick one', + stepDefinition: makeConditionStepDef({ outgoing: [ { stepId: 'a', buttonText: 'A', answer: 'Yes' }, { stepId: 'b', buttonText: 'B', answer: 'No' }, ], - }, + }), }, ], }); @@ -508,29 +526,26 @@ describe('ForestServerWorkflowPort', () => { stepName: 'step-1', stepIndex: 0, done: true, - stepDefinition: { - type: 'condition', + stepDefinition: makeConditionStepDef({ title: 'Decide', - prompt: 'pick one', outgoing: [ { stepId: 'step-2', buttonText: 'Yes', answer: 'Yes' }, { stepId: 'step-end', buttonText: 'No', answer: 'No' }, ], - }, + }), }, { stepName: 'step-2', stepIndex: 1, done: false, - stepDefinition: { - type: 'condition', + stepDefinition: makeConditionStepDef({ title: 'Next', prompt: 'choose', outgoing: [ { stepId: 'end-a', buttonText: 'A', answer: 'Yes' }, { stepId: 'end-b', buttonText: 'B', answer: 'No' }, ], - }, + }), }, ], }), diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 99feb048ef..ad40ceb9ed 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -2,26 +2,61 @@ import type { ServerHydratedWorkflowRun, ServerStepHistory, ServerUserProfile, + ServerWorkflowCondition, + ServerWorkflowTask, } from '../../src/adapters/server-types'; import { z } from 'zod'; import toAvailableStepExecution from '../../src/adapters/run-to-available-step-mapper'; +import { + ServerStepExecutionTypeEnum, + ServerStepTypeEnum, + ServerTaskTypeEnum, +} from '../../src/adapters/server-types'; import { DomainValidationError, InvalidStepDefinitionError } from '../../src/errors'; import { StepType } from '../../src/types/validated/step-definition'; +function makeTaskStepDef( + overrides: Partial & { + taskType?: ServerTaskTypeEnum | string; + } = {}, +): ServerWorkflowTask { + return { + type: ServerStepTypeEnum.Task, + taskType: ServerTaskTypeEnum.GetData, + title: 'Task', + prompt: 'prompt', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [{ stepId: 'next', buttonText: null }], + ...overrides, + } as ServerWorkflowTask; +} + +function makeConditionStepDef( + overrides: Partial = {}, +): ServerWorkflowCondition { + return { + type: ServerStepTypeEnum.Condition, + title: 'Decide', + prompt: 'p', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [ + { stepId: 'a', buttonText: null, answer: 'Yes' }, + { stepId: 'b', buttonText: null, answer: 'No' }, + ], + ...overrides, + }; +} + function makeStepHistory(overrides: Partial = {}): ServerStepHistory { return { stepName: 'step-a', stepIndex: 0, done: false, - stepDefinition: { - type: 'task', - taskType: 'get-data', - title: 'Task', - prompt: 'prompt', - outgoing: { stepId: 'next', buttonText: null }, - }, + stepDefinition: makeTaskStepDef(), ...overrides, }; } @@ -72,7 +107,11 @@ describe('toAvailableStepExecution', () => { recordId: ['123'], stepIndex: 0, }, - stepDefinition: { type: StepType.ReadRecord, prompt: 'prompt' }, + stepDefinition: { + type: StepType.ReadRecord, + prompt: 'prompt', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }, previousSteps: [], user: expect.objectContaining({ id: 7, email: 'alban@forestadmin.com' }), }); @@ -165,26 +204,27 @@ describe('toAvailableStepExecution', () => { expect(toAvailableStepExecution(run)).toBeNull(); }); - it('should strip unknown server keys (e.g. automaticExecution) from guidance step without throwing', () => { + it('should forward executionType from the server to the guidance step definition', () => { const run = makeRun({ workflowHistory: [ makeStepHistory({ - stepDefinition: { - type: 'task', - taskType: 'guideline', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.Guideline, title: 'guidance', prompt: 'follow the guide', - automaticExecution: true, - outgoing: { stepId: 'next', buttonText: null }, - }, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }), }), ], }); const result = toAvailableStepExecution(run); - expect(result?.stepDefinition).toEqual({ type: StepType.Guidance, prompt: 'follow the guide' }); - expect(result?.stepDefinition).not.toHaveProperty('automaticExecution'); + expect(result?.stepDefinition).toEqual({ + type: StepType.Guidance, + prompt: 'follow the guide', + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }); }); describe('previousSteps', () => { @@ -196,28 +236,17 @@ describe('toAvailableStepExecution', () => { stepIndex: 0, done: true, context: { status: 'success' }, - stepDefinition: { - type: 'task', - taskType: 'update-data', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.UpdateData, title: 't', - prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: true, context: { status: 'success', selectedOption: 'Yes' }, - stepDefinition: { - type: 'condition', - title: 'c', - prompt: 'p', - outgoing: [ - { stepId: 'a', buttonText: null, answer: 'Yes' }, - { stepId: 'b', buttonText: null, answer: 'No' }, - ], - }, + stepDefinition: makeConditionStepDef({ title: 'c' }), }), makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), ], @@ -243,13 +272,10 @@ describe('toAvailableStepExecution', () => { stepIndex: 0, done: true, context: { legacyData: 'from-frontend' }, - stepDefinition: { - type: 'task', - taskType: 'update-data', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.UpdateData, title: 't', - prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], @@ -320,13 +346,11 @@ describe('toAvailableStepExecution', () => { stepIndex: 0, done: true, context: { status: 'success' }, - stepDefinition: { - type: 'task', - taskType: 'guideline', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.Guideline, title: 'Guide', prompt: 'Please review', - outgoing: { stepId: 'next', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], @@ -350,13 +374,11 @@ describe('toAvailableStepExecution', () => { stepIndex: 0, done: true, context: { status: 'error', error: 'Guide failed' }, - stepDefinition: { - type: 'task', - taskType: 'guideline', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.Guideline, title: 'Guide', prompt: 'Please review', - outgoing: { stepId: 'next', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], @@ -381,14 +403,12 @@ describe('toAvailableStepExecution', () => { stepIndex: 0, done: true, context: { status: 'success' }, - stepDefinition: { - type: 'task', - taskType: 'mcp-server', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.McpServer, title: 'MCP', prompt: 'Run tool', mcpServerId: 'srv-1', - outgoing: { stepId: 'next', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], @@ -422,29 +442,45 @@ describe('toAvailableStepExecution', () => { [ 'start-sub-workflow', { - type: 'start-sub-workflow', + type: ServerStepTypeEnum.StartSubWorkflow, title: 't', prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [{ stepId: 'x', buttonText: null }], workflowId: 'wf-2', }, ], [ 'close-sub-workflow', { - type: 'close-sub-workflow', - outgoing: { stepId: 'x', buttonText: null }, + type: ServerStepTypeEnum.CloseSubWorkflow, + title: 'Close', + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [{ stepId: 'x', buttonText: null }], parentWorkflowId: null, }, ], - ['end', { type: 'end', title: 'End' }], + [ + 'end', + { + type: ServerStepTypeEnum.End, + title: 'End', + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [], + }, + ], [ 'escalation', { - type: 'escalation', + type: ServerStepTypeEnum.Escalation, title: 'Escalation', prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [{ stepId: 'x', buttonText: null }], inboxId: null, }, ], @@ -462,13 +498,10 @@ describe('toAvailableStepExecution', () => { stepIndex: 1, done: true, context: { status: 'success' }, - stepDefinition: { - type: 'task', - taskType: 'guideline', + stepDefinition: makeTaskStepDef({ + taskType: ServerTaskTypeEnum.Guideline, title: 't', - prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's2', stepIndex: 2, done: false }), ], @@ -488,13 +521,10 @@ describe('toAvailableStepExecution', () => { stepName: 's0', stepIndex: 0, done: true, - stepDefinition: { - type: 'task', - taskType: 'unknown-future-type' as never, + stepDefinition: makeTaskStepDef({ + taskType: 'unknown-future-type' as ServerTaskTypeEnum, title: 't', - prompt: 'p', - outgoing: { stepId: 'x', buttonText: null }, - }, + }), }), makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), ], @@ -603,7 +633,17 @@ describe('toAvailableStepExecution', () => { it('should propagate mapper errors from toStepDefinition', () => { const run = makeRun({ - workflowHistory: [makeStepHistory({ stepDefinition: { type: 'end', title: 'End' } })], + workflowHistory: [ + makeStepHistory({ + stepDefinition: { + type: ServerStepTypeEnum.End, + title: 'End', + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [], + }, + }), + ], }); expect(() => toAvailableStepExecution(run)).toThrow(); diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index fa9790c500..ccbd0d6993 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -6,21 +6,35 @@ import type { ServerWorkflowEscalation, ServerWorkflowStep, ServerWorkflowTask, + ServerWorkflowTransition, } from '../../src/adapters/server-types'; +import { + ServerStepExecutionTypeEnum, + ServerStepTypeEnum, + ServerTaskTypeEnum, +} from '../../src/adapters/server-types'; import toStepDefinition from '../../src/adapters/step-definition-mapper'; import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../../src/errors'; import { StepType } from '../../src/types/validated/step-definition'; -function makeTask(overrides: Partial = {}): ServerWorkflowTask { +const defaultTransition: ServerWorkflowTransition = { stepId: 'next', buttonText: null }; + +function makeTask( + overrides: Partial & { + taskType?: ServerTaskTypeEnum | string; + } = {}, +): ServerWorkflowTask { return { - type: 'task', - taskType: 'get-data', + type: ServerStepTypeEnum.Task, + taskType: ServerTaskTypeEnum.GetData, title: 'Test task', prompt: 'Do something', - outgoing: { stepId: 'next', buttonText: null }, + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [defaultTransition], ...overrides, - }; + } as ServerWorkflowTask; } function makeCondition( @@ -28,9 +42,11 @@ function makeCondition( overrides: Partial = {}, ): ServerWorkflowCondition { return { - type: 'condition', + type: ServerStepTypeEnum.Condition, title: 'Test condition', prompt: 'Choose one', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, outgoing, ...overrides, }; @@ -39,44 +55,51 @@ function makeCondition( describe('toStepDefinition', () => { describe('task mapping', () => { it('should map task with get-data taskType to read-record', () => { - const task = makeTask({ taskType: 'get-data', prompt: 'read it' }); + const task = makeTask({ taskType: ServerTaskTypeEnum.GetData, prompt: 'read it' }); expect(toStepDefinition(task)).toEqual({ type: StepType.ReadRecord, prompt: 'read it', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with update-data taskType to update-record', () => { - const task = makeTask({ taskType: 'update-data', prompt: 'update it' }); + const task = makeTask({ taskType: ServerTaskTypeEnum.UpdateData, prompt: 'update it' }); expect(toStepDefinition(task)).toEqual({ type: StepType.UpdateRecord, prompt: 'update it', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with trigger-action taskType to trigger-action', () => { - const task = makeTask({ taskType: 'trigger-action', prompt: 'trigger it' }); + const task = makeTask({ taskType: ServerTaskTypeEnum.TriggerAction, prompt: 'trigger it' }); expect(toStepDefinition(task)).toEqual({ type: StepType.TriggerAction, prompt: 'trigger it', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with load-related-record taskType to load-related-record', () => { - const task = makeTask({ taskType: 'load-related-record', prompt: 'load it' }); + const task = makeTask({ + taskType: ServerTaskTypeEnum.LoadRelatedRecord, + prompt: 'load it', + }); expect(toStepDefinition(task)).toEqual({ type: StepType.LoadRelatedRecord, prompt: 'load it', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with mcp-server taskType to mcp and include mcpServerId', () => { const task = makeTask({ - taskType: 'mcp-server', + taskType: ServerTaskTypeEnum.McpServer, prompt: 'run mcp', mcpServerId: 'mcp-abc', }); @@ -85,47 +108,73 @@ describe('toStepDefinition', () => { type: StepType.Mcp, prompt: 'run mcp', mcpServerId: 'mcp-abc', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with mcp-server taskType without mcpServerId', () => { - const task = makeTask({ taskType: 'mcp-server', prompt: 'run mcp' }); + const task = makeTask({ taskType: ServerTaskTypeEnum.McpServer, prompt: 'run mcp' }); expect(toStepDefinition(task)).toEqual({ type: StepType.Mcp, prompt: 'run mcp', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); it('should map task with guideline taskType to guidance', () => { - const task = makeTask({ taskType: 'guideline', prompt: 'guide them' }); + const task = makeTask({ taskType: ServerTaskTypeEnum.Guideline, prompt: 'guide them' }); expect(toStepDefinition(task)).toEqual({ type: StepType.Guidance, prompt: 'guide them', + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); - it('should preserve automaticExecution when true', () => { - const task = makeTask({ taskType: 'get-data', automaticExecution: true }); + it('should preserve executionType=fully-automated', () => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.GetData, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }); - expect(toStepDefinition(task)).toMatchObject({ automaticExecution: true }); + expect(toStepDefinition(task)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }); }); - it('should preserve automaticExecution when false', () => { - const task = makeTask({ taskType: 'get-data', automaticExecution: false }); + it('should preserve executionType=automated-with-confirmation', () => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.GetData, + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }); - expect(toStepDefinition(task)).toMatchObject({ automaticExecution: false }); + expect(toStepDefinition(task)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }); }); - it('should omit automaticExecution when undefined on the server step', () => { - const task = makeTask({ taskType: 'get-data' }); + it('should preserve executionType=manual', () => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.GetData, + executionType: ServerStepExecutionTypeEnum.Manual, + }); + + expect(toStepDefinition(task)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.Manual, + }); + }); + + it('should omit executionType when undefined on the server step', () => { + // Strip executionType so the mapper does not forward it (real-world legacy step). + const task = makeTask({ taskType: ServerTaskTypeEnum.GetData }); + delete (task as { executionType?: unknown }).executionType; - expect(toStepDefinition(task)).not.toHaveProperty('automaticExecution'); + expect(toStepDefinition(task)).not.toHaveProperty('executionType'); }); it('should throw InvalidStepDefinitionError for unknown taskType', () => { - const task = makeTask({ taskType: 'unknown-task' as ServerWorkflowTask['taskType'] }); + const task = makeTask({ taskType: 'unknown-task' as ServerTaskTypeEnum }); expect(() => toStepDefinition(task)).toThrow(InvalidStepDefinitionError); expect(() => toStepDefinition(task)).toThrow('Unknown taskType: "unknown-task"'); @@ -143,6 +192,7 @@ describe('toStepDefinition', () => { type: StepType.Condition, prompt: 'Choose one', options: ['Yes', 'No'], + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -156,6 +206,7 @@ describe('toStepDefinition', () => { type: StepType.Condition, prompt: 'Choose one', options: ['Approve', 'Reject'], + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -170,6 +221,20 @@ describe('toStepDefinition', () => { }); }); + it('should preserve executionType=manual on condition steps', () => { + const condition = makeCondition( + [ + { stepId: 's1', buttonText: null, answer: 'Yes' }, + { stepId: 's2', buttonText: null, answer: 'No' }, + ], + { executionType: ServerStepExecutionTypeEnum.Manual }, + ); + + expect(toStepDefinition(condition)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.Manual, + }); + }); + it('should throw InvalidStepDefinitionError when fewer than 2 options', () => { const condition = makeCondition([{ stepId: 's1', buttonText: 'Only' }]); @@ -200,7 +265,14 @@ describe('toStepDefinition', () => { describe('unsupported step types', () => { it('should throw UnsupportedStepTypeError for end', () => { - const step: ServerWorkflowEnd = { type: 'end', title: 'End', prompt: 'Done' }; + const step: ServerWorkflowEnd = { + type: ServerStepTypeEnum.End, + title: 'End', + prompt: 'Done', + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [], + }; expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); expect(() => toStepDefinition(step)).toThrow( @@ -210,10 +282,12 @@ describe('toStepDefinition', () => { it('should throw UnsupportedStepTypeError for escalation', () => { const step: ServerWorkflowEscalation = { - type: 'escalation', + type: ServerStepTypeEnum.Escalation, title: 'Escalate', prompt: 'To whom', - outgoing: { stepId: 'next', buttonText: null }, + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + automaticCompletion: false, + outgoing: [defaultTransition], inboxId: null, }; @@ -222,10 +296,12 @@ describe('toStepDefinition', () => { it('should throw UnsupportedStepTypeError for start-sub-workflow', () => { const step: ServerStartSubWorkflow = { - type: 'start-sub-workflow', + type: ServerStepTypeEnum.StartSubWorkflow, title: 'Start sub', prompt: 'Run sub', - outgoing: { stepId: 'next', buttonText: null }, + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [defaultTransition], workflowId: 'sub-wf', }; @@ -234,8 +310,11 @@ describe('toStepDefinition', () => { it('should throw UnsupportedStepTypeError for close-sub-workflow', () => { const step: ServerCloseSubWorkflow = { - type: 'close-sub-workflow', - outgoing: { stepId: 'next', buttonText: null }, + type: ServerStepTypeEnum.CloseSubWorkflow, + title: 'Close sub', + executionType: ServerStepExecutionTypeEnum.Manual, + automaticCompletion: false, + outgoing: [defaultTransition], parentWorkflowId: null, }; diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts index 98761d6400..f9c68d1f97 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -4,6 +4,7 @@ import type { RecordRef } from '../../src/types/validated/collection'; import type { ConditionStepDefinition } from '../../src/types/validated/step-definition'; import type { ConditionStepOutcome } from '../../src/types/validated/step-outcome'; +import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { RunStorePortError } from '../../src/errors'; import ConditionStepExecutor from '../../src/executors/condition-step-executor'; import SchemaCache from '../../src/schema-cache'; @@ -386,4 +387,70 @@ describe('ConditionStepExecutor', () => { expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); + + describe('executionType=Manual', () => { + it('returns awaiting-input without calling AI or saving when no incomingPendingData', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + }), + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(mockModel.invoke).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('persists the user-selected option without calling AI when incomingPendingData is provided', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + incomingPendingData: { selectedOption: 'Approve' }, + }), + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Approve'); + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(mockModel.invoke).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Approve', reasoning: 'Selected by user' }, + executionResult: { answer: 'Approve' }, + }); + }); + + it('rejects an option not in step.options even in Manual mode', async () => { + const mockModel = makeMockModel(); + const runStore = makeMockRunStore(); + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + runStore, + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + incomingPendingData: { selectedOption: 'Maybe' }, + }), + ); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index e7fdab82ef..5dc758c746 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -6,6 +6,7 @@ import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-ex import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/validated/collection'; import type { LoadRelatedRecordStepDefinition } from '../../src/types/validated/step-definition'; +import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError } from '../../src/errors'; import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; import SchemaCache from '../../src/schema-cache'; @@ -174,7 +175,7 @@ function makePendingExecution( } describe('LoadRelatedRecordStepExecutor', () => { - describe('automaticExecution: BelongsTo — load direct (Branch B)', () => { + describe('executionType=FullyAutomated: BelongsTo — load direct (Branch B)', () => { it('fetches 1 related record and returns success', async () => { const agentPort = makeMockAgentPort(); const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); @@ -183,7 +184,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -216,7 +217,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('automaticExecution: HasMany — 2 AI calls (Branch B)', () => { + describe('executionType=FullyAutomated: HasMany — 2 AI calls (Branch B)', () => { it('runs selectRelevantFields + selectBestRecord to pick the best candidate', async () => { const hasManySchema = makeCollectionSchema({ fields: [ @@ -282,7 +283,7 @@ describe('LoadRelatedRecordStepExecutor', () => { customers: hasManySchema, addresses: addressSchema, }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -360,7 +361,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -403,7 +404,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -469,7 +470,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -528,7 +529,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -542,7 +543,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('automaticExecution: HasOne — load direct (Branch B)', () => { + describe('executionType=FullyAutomated: HasOne — load direct (Branch B)', () => { it('fetches 1 related record (same path as BelongsTo) and returns success', async () => { const hasOneSchema = makeCollectionSchema({ fields: [ @@ -564,7 +565,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasOneSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -587,7 +588,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('without automaticExecution: awaiting-input (Branch C)', () => { + describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { it('saves AI suggestion in pendingData and returns awaiting-input (single record — no field/record AI calls)', async () => { const agentPort = makeMockAgentPort(); // returns 1 record: orders #99 const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); @@ -1089,7 +1090,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1121,7 +1122,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1162,7 +1163,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runId: 'run-1', stepIndex: 0, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1219,7 +1220,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1292,7 +1293,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1322,7 +1323,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1436,7 +1437,9 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { - const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const context = makeContext({ + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + }); const executor = new LoadRelatedRecordStepExecutor(context); const result = await executor.execute(); @@ -1582,7 +1585,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1601,7 +1604,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1713,7 +1716,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Order' }, }), }); @@ -1746,7 +1749,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 1 }, }), }); @@ -1784,7 +1787,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 99 }, }), }); @@ -1799,7 +1802,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const { model, bindTools } = makeMockModel({ relationName: 'Orders', reasoning: 'r' }); const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index bd52a2e751..62e8f59a4f 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -6,6 +6,7 @@ import type { McpStepDefinition } from '../../src/types/validated/step-definitio import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; +import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { RunStorePortError, StepStateError } from '../../src/errors'; import McpStepExecutor from '../../src/executors/mcp-step-executor'; import SchemaCache from '../../src/schema-cache'; @@ -124,7 +125,7 @@ function makeContext( // --------------------------------------------------------------------------- describe('McpStepExecutor', () => { - describe('automaticExecution: direct execution (Branch B)', () => { + describe('executionType=FullyAutomated: direct execution (Branch B)', () => { it('invokes the tool and returns success', async () => { const invokeFn = jest.fn().mockResolvedValue({ result: 'notification sent' }); const tool = new MockRemoteTool({ @@ -139,7 +140,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -189,7 +190,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -241,7 +242,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -276,7 +277,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -297,7 +298,7 @@ describe('McpStepExecutor', () => { }); }); - describe('without automaticExecution: awaiting-input (Branch C)', () => { + describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { it('saves pendingData and returns awaiting-input', async () => { const { model } = makeMockModel('send_notification', { message: 'Hello' }); const runStore = makeMockRunStore(); @@ -455,7 +456,10 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ mcpServerId: 'server-B', automaticExecution: true }), + stepDefinition: makeStep({ + mcpServerId: 'server-B', + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }), }); const executor = new McpStepExecutor(context, [toolA, toolB, toolB2]); @@ -542,7 +546,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -601,7 +605,7 @@ describe('McpStepExecutor', () => { const { model } = makeMockModel('send_notification', {}); const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -667,7 +671,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore: mockRunStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -692,7 +696,7 @@ describe('McpStepExecutor', () => { const logger = { info: jest.fn(), error: jest.fn() }; const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -879,7 +883,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index f2c62ee988..866c840c22 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -6,6 +6,7 @@ import type { TriggerRecordActionStepExecutionData } from '../../src/types/step- import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; import type { TriggerActionStepDefinition } from '../../src/types/validated/step-definition'; +import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; import SchemaCache from '../../src/schema-cache'; @@ -144,7 +145,7 @@ function makeContext( } describe('TriggerRecordActionStepExecutor', () => { - describe('automaticExecution: trigger direct (Branch B)', () => { + describe('executionType=FullyAutomated: trigger direct (Branch B)', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); @@ -157,7 +158,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -187,7 +188,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); }); - describe('without automaticExecution: awaiting-input (Branch C)', () => { + describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { it('saves pendingAction and returns awaiting-input', async () => { const mockModel = makeMockModel({ actionName: 'Send Welcome Email', @@ -227,7 +228,9 @@ describe('TriggerRecordActionStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ automaticExecution: false }), + stepDefinition: makeStep({ + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -464,7 +467,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); describe('UnsupportedActionFormError (form detection)', () => { - it('throws when the action has a form and automaticExecution is true', async () => { + it('throws when the action has a form and executionType is FullyAutomated', async () => { const agentPort = makeMockAgentPort(); (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); const mockModel = makeMockModel({ @@ -476,7 +479,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -496,7 +499,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('supports form-bearing actions when automaticExecution is false (frontend handles the form)', async () => { + it('supports form-bearing actions when executionType is not FullyAutomated (frontend handles the form)', async () => { const agentPort = makeMockAgentPort(); // hasForm would return true if called — but it should not be called in this branch. (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); @@ -547,7 +550,7 @@ describe('TriggerRecordActionStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -577,7 +580,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -609,7 +612,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -631,7 +634,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -659,7 +662,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -689,7 +692,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -795,7 +798,9 @@ describe('TriggerRecordActionStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { - const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const context = makeContext({ + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + }); const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -814,7 +819,7 @@ describe('TriggerRecordActionStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -928,7 +933,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1046,7 +1051,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { actionDisplayName: 'Send Welcome Email' }, }), }); @@ -1062,7 +1067,7 @@ describe('TriggerRecordActionStepExecutor', () => { ); }); - it('still goes through awaiting-input when automaticExecution is false', async () => { + it('still goes through awaiting-input when executionType is not FullyAutomated', async () => { const mockModel = makeMockModel(); const runStore = makeMockRunStore(); const context = makeContext({ @@ -1084,7 +1089,7 @@ describe('TriggerRecordActionStepExecutor', () => { const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'r' }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1164,7 +1169,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(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 9c1866e092..d4fc46c3f6 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 @@ -6,6 +6,7 @@ import type { UpdateRecordStepExecutionData } from '../../src/types/step-executi import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; import type { UpdateRecordStepDefinition } from '../../src/types/validated/step-definition'; +import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; import SchemaCache from '../../src/schema-cache'; @@ -141,7 +142,7 @@ function makeContext( } describe('UpdateRecordStepExecutor', () => { - describe('automaticExecution: update direct (Branch B)', () => { + describe('executionType=FullyAutomated: update direct (Branch B)', () => { it('updates the record and returns success', async () => { const updatedValues = { status: 'active', name: 'John Doe' }; const agentPort = makeMockAgentPort(updatedValues); @@ -153,7 +154,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -180,7 +181,7 @@ describe('UpdateRecordStepExecutor', () => { }); }); - describe('without automaticExecution: awaiting-input (Branch C)', () => { + describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { it('saves execution and returns awaiting-input', async () => { const mockModel = makeMockModel({ input: { fieldName: 'Status', value: 'active', reasoning: 'User requested status change' }, @@ -479,14 +480,14 @@ describe('UpdateRecordStepExecutor', () => { }); describe('resolveFieldName failure', () => { - it('returns error when field is not found during automaticExecution (Branch B)', async () => { + it('returns error when field is not found during executionType=FullyAutomated (Branch B)', async () => { // AI returns a display name that doesn't match any field in the schema const mockModel = makeMockModel({ input: { fieldName: 'NonExistentField', value: 'test', reasoning: 'test' }, }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -595,7 +596,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -654,7 +655,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -699,7 +700,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -718,7 +719,9 @@ describe('UpdateRecordStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { - const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const context = makeContext({ + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -742,7 +745,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -761,7 +764,7 @@ describe('UpdateRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -826,7 +829,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -915,7 +918,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status', value: 'active' }, }), }); @@ -931,7 +934,7 @@ describe('UpdateRecordStepExecutor', () => { ); }); - it('still goes through awaiting-input when automaticExecution is false', async () => { + it('still goes through awaiting-input when executionType is not FullyAutomated', async () => { const mockModel = makeMockModel(); const runStore = makeMockRunStore(); const context = makeContext({ @@ -956,7 +959,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { selectedRecordStepIndex: 0 }, }), }); @@ -970,7 +973,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when fieldDisplayName is provided without value', async () => { const context = makeContext({ stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status' }, }), }); @@ -984,7 +987,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when value is provided without fieldDisplayName', async () => { const context = makeContext({ stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { value: 'active' }, }), }); @@ -1006,7 +1009,7 @@ describe('UpdateRecordStepExecutor', () => { runStore, workflowPort, stepDefinition: makeStep({ - automaticExecution: true, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Age', value: 42 }, }), }); @@ -1390,7 +1393,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); From 851e5ee6942662cf09a4852f5d5906c0dc8450b7 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 21 May 2026 14:30:29 +0200 Subject: [PATCH 3/7] fix: tests --- .../run-to-available-step-mapper.test.ts | 27 ------------------- .../test/build-workflow-executor.test.ts | 6 +++-- .../update-record-step-executor.test.ts | 4 +-- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 820943f963..08bb1b4006 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -154,33 +154,6 @@ describe('toAvailableStepExecution', () => { expect(result?.stepIndex).toBe(2); }); - it('errored step (done:false + context.error) is skipped — next pending step is returned', () => { - // Scenario: back changed errored steps to done:false so the front can offer Continue/Revise. - // The executor must skip the errored step and pick the next pending one. - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepName: 's0', stepIndex: 0, done: false, context: { error: 'boom' } }), - makeStepHistory({ stepName: 's1', stepIndex: 1, done: false }), - ], - }); - - const result = toAvailableStepExecution(run); - - expect(result?.stepId).toBe('s1'); - expect(result?.stepIndex).toBe(1); - }); - - it('returns null when the only non-done step is errored', () => { - const run = makeRun({ - workflowHistory: [ - makeStepHistory({ stepIndex: 0, done: true }), - makeStepHistory({ stepIndex: 1, done: false, context: { error: 'failed' } }), - ], - }); - - expect(toAvailableStepExecution(run)).toBeNull(); - }); - it('should forward executionType from the server to the guidance step definition', () => { const run = makeRun({ workflowHistory: [ diff --git a/packages/workflow-executor/test/build-workflow-executor.test.ts b/packages/workflow-executor/test/build-workflow-executor.test.ts index fda4b83943..777c4b1188 100644 --- a/packages/workflow-executor/test/build-workflow-executor.test.ts +++ b/packages/workflow-executor/test/build-workflow-executor.test.ts @@ -117,10 +117,12 @@ describe('buildInMemoryExecutor', () => { }); }); - it('passes pollingIntervalMs with default value of 5000', () => { + it('passes pollingIntervalMs with default value of 30000', () => { buildInMemoryExecutor(BASE_OPTIONS); - expect(MockedRunner).toHaveBeenCalledWith(expect.objectContaining({ pollingIntervalMs: 5000 })); + expect(MockedRunner).toHaveBeenCalledWith( + expect.objectContaining({ pollingIntervalMs: 30000 }), + ); }); it('passes custom pollingIntervalMs', () => { 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 616d79ab05..a10b618efe 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 @@ -516,7 +516,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -556,7 +556,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, workflowPort: makeMockWorkflowPort({ customers: ambiguousSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); From a3e53903fac5cf34485009d753f5c14cfdb27fc8 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 21 May 2026 15:22:42 +0200 Subject: [PATCH 4/7] fix(review): log warnings when executionType is not supported --- .../src/adapters/console-logger.ts | 4 + .../src/adapters/pretty-logger.ts | 5 ++ .../src/executors/base-step-executor.ts | 24 ++++++ .../src/executors/condition-step-executor.ts | 5 ++ .../src/executors/guidance-step-executor.ts | 6 ++ .../load-related-record-step-executor.ts | 8 ++ .../src/executors/mcp-step-executor.ts | 8 ++ .../executors/read-record-step-executor.ts | 6 ++ .../trigger-record-action-step-executor.ts | 8 ++ .../executors/update-record-step-executor.ts | 8 ++ .../src/ports/logger-port.ts | 1 + .../forest-server-workflow-port.test.ts | 2 +- ...n-client-activity-log-port-factory.test.ts | 2 +- ...restadmin-client-activity-log-port.test.ts | 2 +- .../test/adapters/with-retry.test.ts | 1 + .../test/executors/base-step-executor.test.ts | 2 +- .../executors/condition-step-executor.test.ts | 2 +- .../executors/guidance-step-executor.test.ts | 2 +- .../load-related-record-step-executor.test.ts | 4 +- .../test/executors/mcp-step-executor.test.ts | 76 +++++++++++++++++-- .../read-record-step-executor.test.ts | 4 +- ...rigger-record-action-step-executor.test.ts | 4 +- .../update-record-step-executor.test.ts | 4 +- .../test/http/executor-http-server.test.ts | 4 +- .../workflow-executor/test/runner.test.ts | 8 +- .../test/stores/database-store.test.ts | 4 +- 26 files changed, 175 insertions(+), 29 deletions(-) diff --git a/packages/workflow-executor/src/adapters/console-logger.ts b/packages/workflow-executor/src/adapters/console-logger.ts index cb9ca735a1..090aaf5845 100644 --- a/packages/workflow-executor/src/adapters/console-logger.ts +++ b/packages/workflow-executor/src/adapters/console-logger.ts @@ -5,6 +5,10 @@ export default class ConsoleLogger implements Logger { console.error(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context })); } + warn(message: string, context: Record): void { + console.warn(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context })); + } + info(message: string, context: Record): void { console.info(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context })); } diff --git a/packages/workflow-executor/src/adapters/pretty-logger.ts b/packages/workflow-executor/src/adapters/pretty-logger.ts index b6f4cc0f21..35d23d5f65 100644 --- a/packages/workflow-executor/src/adapters/pretty-logger.ts +++ b/packages/workflow-executor/src/adapters/pretty-logger.ts @@ -10,6 +10,11 @@ export default class PrettyLogger implements Logger { console.info(this.format(pc.cyan('info '), message, context)); } + warn(message: string, context: Record): void { + // eslint-disable-next-line no-console + console.warn(this.format(pc.yellow('warn '), message, context)); + } + error(message: string, context: Record): void { // eslint-disable-next-line no-console console.error(this.format(pc.red('error'), message, context)); diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 5ec331d407..6a27dbed72 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,3 +1,4 @@ +import type { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import type { CreateActivityLogArgs } from '../ports/activity-log-port'; import type { AgentPort } from '../ports/agent-port'; import type { @@ -136,6 +137,29 @@ export default abstract class BaseStepExecutor { const args = this.buildActivityLogArgs(); if (!args) return this.runWithTimeout(); diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 20c4e7ef8f..31ee8d57b5 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -60,6 +60,11 @@ export default class ConditionStepExecutor extends BaseStepExecutor { + this.warnIfUnsupportedExecutionType( + [ServerStepExecutionTypeEnum.Manual, ServerStepExecutionTypeEnum.FullyAutomated], + ServerStepExecutionTypeEnum.FullyAutomated, + ); + const { stepDefinition: step, incomingPendingData } = this.context; // Manual mode: the user picks the option from the frontend. Wait for their input diff --git a/packages/workflow-executor/src/executors/guidance-step-executor.ts b/packages/workflow-executor/src/executors/guidance-step-executor.ts index e63028c122..632a253d79 100644 --- a/packages/workflow-executor/src/executors/guidance-step-executor.ts +++ b/packages/workflow-executor/src/executors/guidance-step-executor.ts @@ -2,12 +2,18 @@ import type { StepExecutionResult } from '../types/execution-context'; import type { GuidanceStepDefinition } from '../types/validated/step-definition'; import type { RecordStepStatus } from '../types/validated/step-outcome'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; import patchBodySchemas from '../http/pending-data-validators'; export default class GuidanceStepExecutor extends BaseStepExecutor { protected async doExecute(): Promise { + this.warnIfUnsupportedExecutionType( + [ServerStepExecutionTypeEnum.Manual], + ServerStepExecutionTypeEnum.Manual, + ); + const { incomingPendingData } = this.context; if (!incomingPendingData) { diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 9042b843b3..a16a65b9fa 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -49,6 +49,14 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { + this.warnIfUnsupportedExecutionType( + [ + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ], + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ); + // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/executors/mcp-step-executor.ts b/packages/workflow-executor/src/executors/mcp-step-executor.ts index 8295da30a5..8b9abbdff2 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -70,6 +70,14 @@ export default class McpStepExecutor extends BaseStepExecutor } protected async doExecute(): Promise { + this.warnIfUnsupportedExecutionType( + [ + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ], + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ); + // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 950f6c6bc5..8b894f97f0 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -7,6 +7,7 @@ import type { ReadRecordStepDefinition } from '../types/validated/step-definitio import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { NoReadableFieldsError, NoResolvedFieldsError } from '../errors'; import RecordStepExecutor from './record-step-executor'; @@ -30,6 +31,11 @@ export default class ReadRecordStepExecutor extends RecordStepExecutor { + this.warnIfUnsupportedExecutionType( + [ServerStepExecutionTypeEnum.FullyAutomated], + ServerStepExecutionTypeEnum.FullyAutomated, + ); + const { stepDefinition: step } = this.context; const { preRecordedArgs } = step; const records = await this.getAvailableRecordRefs(); diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index 64b22bf256..0c790f02bf 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -62,6 +62,14 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< } protected async doExecute(): Promise { + this.warnIfUnsupportedExecutionType( + [ + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ], + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ); + // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, 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 3f457576ac..0bc6148588 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -124,6 +124,14 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { + this.warnIfUnsupportedExecutionType( + [ + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ], + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ); + // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/ports/logger-port.ts b/packages/workflow-executor/src/ports/logger-port.ts index e615eb7e4b..8fe56a50dc 100644 --- a/packages/workflow-executor/src/ports/logger-port.ts +++ b/packages/workflow-executor/src/ports/logger-port.ts @@ -1,4 +1,5 @@ export interface Logger { error(message: string, context: Record): void; + warn(message: string, context: Record): void; info(message: string, context: Record): void; } diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 5b3fe00632..a159b8f43d 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -314,7 +314,7 @@ describe('ForestServerWorkflowPort', () => { }); it('logs and skips when the mapping throws a non-WorkflowExecutorError', async () => { - const logger = { error: jest.fn(), info: jest.fn() }; + const logger = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; const portWithLogger = new ForestServerWorkflowPort({ ...options, logger }); // Simulate a non-domain error by passing a run whose workflowHistory will // blow up a pure JS operation inside the mapper (missing `find` on non-array). diff --git a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts index 2ff6b0bd07..fd5a89e465 100644 --- a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts +++ b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port-factory.test.ts @@ -4,7 +4,7 @@ import ForestadminClientActivityLogPort from '../../src/adapters/forestadmin-cli import ForestadminClientActivityLogPortFactory from '../../src/adapters/forestadmin-client-activity-log-port-factory'; function makeLogger() { - return { info: jest.fn(), error: jest.fn() }; + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; } function makeService(): jest.Mocked { diff --git a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts index 526f6b696b..33c807dbb4 100644 --- a/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts +++ b/packages/workflow-executor/test/adapters/forestadmin-client-activity-log-port.test.ts @@ -5,7 +5,7 @@ import ForestadminClientActivityLogPort from '../../src/adapters/forestadmin-cli import { ActivityLogCreationError } from '../../src/errors'; function makeLogger() { - return { info: jest.fn(), error: jest.fn() }; + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; } function makeService(): jest.Mocked { diff --git a/packages/workflow-executor/test/adapters/with-retry.test.ts b/packages/workflow-executor/test/adapters/with-retry.test.ts index 0b039b0722..3713b58876 100644 --- a/packages/workflow-executor/test/adapters/with-retry.test.ts +++ b/packages/workflow-executor/test/adapters/with-retry.test.ts @@ -4,6 +4,7 @@ import withRetry from '../../src/adapters/with-retry'; const makeLogger = (): jest.Mocked => ({ info: jest.fn(), + warn: jest.fn(), error: jest.fn(), }); diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index 6cd5c51af0..157ade4248 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -88,7 +88,7 @@ function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { } function makeMockLogger(): Logger { - return { info: jest.fn(), error: jest.fn() }; + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; } function makeMockActivityLogPort(): ExecutionContext['activityLogPort'] { diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts index f9c68d1f97..126990ee88 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -72,7 +72,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), diff --git a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts index eddde2a98f..5031f03914 100644 --- a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts @@ -49,7 +49,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index cdf7306588..dec7ca9cf8 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -145,7 +145,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), @@ -1313,7 +1313,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); it('returns user message and logs cause when agentPort.getRelatedData throws an infra error', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); (agentPort.getRelatedData as jest.Mock).mockRejectedValue( new AgentPortError('getRelatedData', new Error('DB connection lost')), diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index 9ae730fc1b..5e875535fd 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -115,7 +115,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), @@ -243,7 +243,7 @@ describe('McpStepExecutor', () => { tool_calls: [{ name: 'send_notification', args: { message: 'Hi' }, id: 'call_1' }], }) .mockResolvedValueOnce({ tool_calls: [] }); - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore(); const context = makeContext({ model, @@ -331,7 +331,7 @@ describe('McpStepExecutor', () => { it('returns error when saveStepExecution fails (Branch C)', async () => { const { model } = makeMockModel('send_notification', { message: 'Hello' }); - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ saveStepExecution: jest .fn() @@ -600,7 +600,7 @@ describe('McpStepExecutor', () => { }); it('logs the technical message with the requested mcpServerId and loaded mcpServerIds when filter misses', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const toolA = new MockRemoteTool({ name: 'tool_a', sourceId: 'server-A', @@ -667,7 +667,7 @@ describe('McpStepExecutor', () => { invoke: invokeFn, }); const { model } = makeMockModel('send_notification', { message: 'Hello' }); - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ saveStepExecution: jest .fn() @@ -708,7 +708,7 @@ describe('McpStepExecutor', () => { userConfirmed: true, }, }; - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), saveStepExecution: jest @@ -823,7 +823,7 @@ describe('McpStepExecutor', () => { invoke: invokeFn, }); const { model } = makeMockModel('send_notification', {}); - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const context = makeContext({ model, stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), @@ -1035,4 +1035,66 @@ describe('McpStepExecutor', () => { }); }); }); + + describe('executionType=Manual (unsupported)', () => { + it('logs a warning and falls back to the AutomatedWithConfirmation flow', async () => { + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + logger, + runStore, + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + }); + const executor = new McpStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Step received executionType=manual but does not support it'), + expect.objectContaining({ + runId: 'run-1', + stepId: 'mcp-1', + stepIndex: 0, + stepType: StepType.Mcp, + supportedExecutionTypes: [ + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ], + }), + ); + // Falls back to the awaiting-input branch — no tool execution. + expect(result.stepOutcome.status).toBe('awaiting-input'); + }); + + it('does not warn when executionType is undefined', async () => { + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const context = makeContext({ model, logger, stepDefinition: makeStep() }); + const executor = new McpStepExecutor(context, [tool]); + + await executor.execute(); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not warn when executionType is FullyAutomated', async () => { + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + const context = makeContext({ + model, + logger, + stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + }); + const executor = new McpStepExecutor(context, [tool]); + + await executor.execute(); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts index 87c708ca2f..87b53109c0 100644 --- a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts @@ -130,7 +130,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), @@ -676,7 +676,7 @@ describe('ReadRecordStepExecutor', () => { }); it('returns user message and logs cause when agentPort.getRecord throws an infra error', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); // Prod adapter normalizes infra errors into AgentPortError — simulate here. (agentPort.getRecord as jest.Mock).mockRejectedValue( diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index d53b930852..1edd5bda71 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -133,7 +133,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), @@ -621,7 +621,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); it('returns user message and logs cause when agentPort.executeAction throws an infra error', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue( new AgentPortError('executeAction', new Error('DB connection lost')), 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 a10b618efe..bdd6c5d3ed 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 @@ -130,7 +130,7 @@ function makeContext( }, schemaCache: new SchemaCache(), previousSteps: [], - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, activityLogPort: { createPending: jest.fn().mockResolvedValue({ id: 'log-1', index: '0' }), @@ -754,7 +754,7 @@ describe('UpdateRecordStepExecutor', () => { }); it('returns user message and logs cause when agentPort.updateRecord throws an infra error', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue( new AgentPortError('updateRecord', new Error('DB connection lost')), diff --git a/packages/workflow-executor/test/http/executor-http-server.test.ts b/packages/workflow-executor/test/http/executor-http-server.test.ts index bca0b23420..c15abf8780 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -40,7 +40,7 @@ function createServer( overrides: { runner?: Runner; workflowPort?: WorkflowPort; - logger?: { info: jest.Mock; error: jest.Mock }; + logger?: { info: jest.Mock; warn: jest.Mock; error: jest.Mock }; } = {}, ) { return new ExecutorHttpServer({ @@ -247,7 +247,7 @@ describe('ExecutorHttpServer', () => { }); it('returns 503 when hasRunAccess throws', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const workflowPort = createMockWorkflowPort({ hasRunAccess: jest.fn().mockRejectedValue(new Error('orchestrator down')), }); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index de4dcfe41a..b9175a6a19 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -59,7 +59,7 @@ function createMockAiClient() { } function createMockLogger(): jest.Mocked> { - return { info: jest.fn(), error: jest.fn() }; + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; } const VALID_ENV_SECRET = 'a'.repeat(64); @@ -1220,7 +1220,7 @@ describe('StepExecutorFactory.create — factory', () => { workflowPort: {} as WorkflowPort, runStore: {} as RunStore, schemaCache: new SchemaCache(), - logger: { info: jest.fn(), error: jest.fn() }, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, }); const makeRunLogger = () => ({ @@ -1343,7 +1343,7 @@ describe('StepExecutorFactory.create — factory', () => { const rootCause = new Error('root cause'); const error = new Error('wrapper'); (error as Error & { cause: Error }).cause = rootCause; - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const contextConfig: StepContextConfig = { ...makeContextConfig(), aiModelPort: { @@ -1365,7 +1365,7 @@ describe('StepExecutorFactory.create — factory', () => { it('logs cause as undefined when construction error cause is not an Error instance', async () => { const error = new Error('wrapper'); (error as Error & { cause: string }).cause = 'plain string'; - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const contextConfig: StepContextConfig = { ...makeContextConfig(), aiModelPort: { diff --git a/packages/workflow-executor/test/stores/database-store.test.ts b/packages/workflow-executor/test/stores/database-store.test.ts index cab80c9bae..1f3878a793 100644 --- a/packages/workflow-executor/test/stores/database-store.test.ts +++ b/packages/workflow-executor/test/stores/database-store.test.ts @@ -118,7 +118,7 @@ describe('DatabaseStore (SQLite)', () => { .spyOn(badSequelize.getQueryInterface(), 'createTable') .mockRejectedValueOnce(new Error('disk full')); - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; await expect(badStore.init(logger)).rejects.toThrow('disk full'); expect(logger.error).toHaveBeenCalledWith( 'Database migration failed', @@ -129,7 +129,7 @@ describe('DatabaseStore (SQLite)', () => { }); it('close() catches and logs errors instead of throwing', async () => { - const logger = { info: jest.fn(), error: jest.fn() }; + const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; jest.spyOn(sequelize, 'close').mockRejectedValueOnce(new Error('close failed')); await expect(store.close(logger)).resolves.toBeUndefined(); From 77f530113273498e701067b897da22b5f1515775 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Thu, 21 May 2026 15:44:07 +0200 Subject: [PATCH 5/7] fix: review --- .../src/adapters/step-definition-mapper.ts | 28 +++++++++--- .../src/executors/base-step-executor.ts | 8 ++-- .../src/executors/condition-step-executor.ts | 8 ++-- .../src/executors/guidance-step-executor.ts | 7 +-- .../load-related-record-step-executor.ts | 13 +++--- .../src/executors/mcp-step-executor.ts | 13 +++--- .../executors/read-record-step-executor.ts | 6 +-- .../trigger-record-action-step-executor.ts | 16 +++---- .../executors/update-record-step-executor.ts | 13 +++--- .../src/types/validated/step-definition.ts | 15 +++++-- .../executors/condition-step-executor.test.ts | 9 ++-- .../load-related-record-step-executor.test.ts | 43 +++++++++---------- .../test/executors/mcp-step-executor.test.ts | 35 ++++++++------- ...rigger-record-action-step-executor.test.ts | 33 +++++++------- .../update-record-step-executor.test.ts | 37 ++++++++-------- 15 files changed, 142 insertions(+), 142 deletions(-) diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index f7aee015b8..bff89ba127 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -1,5 +1,4 @@ import type { - ServerStepExecutionTypeEnum, ServerTaskTypeEnum, ServerWorkflowCondition, ServerWorkflowStep, @@ -7,8 +6,9 @@ import type { } from './server-types'; import type { ConditionStepDefinition, StepDefinition } from '../types/validated/step-definition'; +import { ServerStepExecutionTypeEnum } from './server-types'; import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../errors'; -import { StepType } from '../types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../types/validated/step-definition'; const TASK_TYPE_TO_STEP_TYPE: Record = { 'get-data': StepType.ReadRecord, @@ -19,6 +19,19 @@ const TASK_TYPE_TO_STEP_TYPE: Record = { guideline: StepType.Guidance, }; +const EXECUTION_TYPE_TO_MODE: Record = { + [ServerStepExecutionTypeEnum.Manual]: StepExecutionMode.Manual, + [ServerStepExecutionTypeEnum.AutomatedWithConfirmation]: + StepExecutionMode.AutomatedWithConfirmation, + [ServerStepExecutionTypeEnum.FullyAutomated]: StepExecutionMode.FullyAutomated, +}; + +function toStepExecutionMode( + executionType: ServerStepExecutionTypeEnum | undefined, +): StepExecutionMode | undefined { + return executionType === undefined ? undefined : EXECUTION_TYPE_TO_MODE[executionType]; +} + function mapTask(task: ServerWorkflowTask): StepDefinition { const stepType = TASK_TYPE_TO_STEP_TYPE[task.taskType]; @@ -26,10 +39,9 @@ function mapTask(task: ServerWorkflowTask): StepDefinition { throw new InvalidStepDefinitionError(`Unknown taskType: "${task.taskType}"`); } - const base: { prompt: string; executionType?: ServerStepExecutionTypeEnum } = { - prompt: task.prompt, - }; - if (task.executionType !== undefined) base.executionType = task.executionType; + const executionType = toStepExecutionMode(task.executionType); + const base: { prompt: string; executionType?: StepExecutionMode } = { prompt: task.prompt }; + if (executionType !== undefined) base.executionType = executionType; switch (stepType) { case StepType.Mcp: @@ -64,11 +76,13 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti ); } + const executionType = toStepExecutionMode(condition.executionType); + return { type: StepType.Condition, prompt: condition.prompt, options, - ...(condition.executionType !== undefined && { executionType: condition.executionType }), + ...(executionType !== undefined && { executionType }), }; } diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 6a27dbed72..f41dfcff58 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,4 +1,3 @@ -import type { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import type { CreateActivityLogArgs } from '../ports/activity-log-port'; import type { AgentPort } from '../ports/agent-port'; import type { @@ -7,7 +6,7 @@ import type { StepExecutionResult, } from '../types/execution-context'; import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepDefinition } from '../types/validated/step-definition'; +import type { StepDefinition, StepExecutionMode } from '../types/validated/step-definition'; import type { StepStatus } from '../types/validated/step-outcome'; import type { BaseMessage, @@ -129,7 +128,6 @@ export default abstract class BaseStepExecutor { this.warnIfUnsupportedExecutionType( - [ServerStepExecutionTypeEnum.Manual, ServerStepExecutionTypeEnum.FullyAutomated], - ServerStepExecutionTypeEnum.FullyAutomated, + [StepExecutionMode.Manual, StepExecutionMode.FullyAutomated], + StepExecutionMode.FullyAutomated, ); const { stepDefinition: step, incomingPendingData } = this.context; // Manual mode: the user picks the option from the frontend. Wait for their input // without ever calling the AI. - const isManual = step.executionType === ServerStepExecutionTypeEnum.Manual; + const isManual = step.executionType === StepExecutionMode.Manual; if (isManual && incomingPendingData === undefined) { return this.buildOutcomeResult({ status: 'awaiting-input' }); diff --git a/packages/workflow-executor/src/executors/guidance-step-executor.ts b/packages/workflow-executor/src/executors/guidance-step-executor.ts index 632a253d79..c53cb4b988 100644 --- a/packages/workflow-executor/src/executors/guidance-step-executor.ts +++ b/packages/workflow-executor/src/executors/guidance-step-executor.ts @@ -2,17 +2,14 @@ import type { StepExecutionResult } from '../types/execution-context'; import type { GuidanceStepDefinition } from '../types/validated/step-definition'; import type { RecordStepStatus } from '../types/validated/step-outcome'; -import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; import patchBodySchemas from '../http/pending-data-validators'; +import { StepExecutionMode } from '../types/validated/step-definition'; export default class GuidanceStepExecutor extends BaseStepExecutor { protected async doExecute(): Promise { - this.warnIfUnsupportedExecutionType( - [ServerStepExecutionTypeEnum.Manual], - ServerStepExecutionTypeEnum.Manual, - ); + this.warnIfUnsupportedExecutionType([StepExecutionMode.Manual], StepExecutionMode.Manual); const { incomingPendingData } = this.context; diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index a16a65b9fa..c1ca0269bd 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -7,7 +7,6 @@ import type { LoadRelatedRecordStepDefinition } from '../types/validated/step-de import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { InvalidAIResponseError, InvalidPreRecordedArgsError, @@ -17,6 +16,7 @@ import { StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; +import { StepExecutionMode } from '../types/validated/step-definition'; const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request. Select the relation to follow. @@ -50,11 +50,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { this.warnIfUnsupportedExecutionType( - [ - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - ServerStepExecutionTypeEnum.FullyAutomated, - ], - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + StepExecutionMode.AutomatedWithConfirmation, ); // Branch A -- Re-entry after pending execution found in RunStore @@ -88,11 +85,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor protected async doExecute(): Promise { this.warnIfUnsupportedExecutionType( - [ - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - ServerStepExecutionTypeEnum.FullyAutomated, - ], - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + StepExecutionMode.AutomatedWithConfirmation, ); // Branch A -- Re-entry after pending execution found in RunStore @@ -96,12 +93,12 @@ export default class McpStepExecutor extends BaseStepExecutor if (!selectedTool) throw new McpToolNotFoundError(toolName); const target: McpToolCall = { name: toolName, sourceId: selectedTool.sourceId, input: args }; - if (this.context.stepDefinition.executionType === ServerStepExecutionTypeEnum.FullyAutomated) { + if (this.context.stepDefinition.executionType === StepExecutionMode.FullyAutomated) { // Branch B -- direct execution return this.executeToolAndPersist(target); } - // Branch C -- Awaiting confirmation (also covers Manual fallback) + // Branch C -- Awaiting confirmation await this.context.runStore.saveStepExecution(this.context.runId, { type: 'mcp', stepIndex: this.context.stepIndex, diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 8b894f97f0..d3a3c57c5e 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -7,9 +7,9 @@ import type { ReadRecordStepDefinition } from '../types/validated/step-definitio import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { NoReadableFieldsError, NoResolvedFieldsError } from '../errors'; import RecordStepExecutor from './record-step-executor'; +import { StepExecutionMode } from '../types/validated/step-definition'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. Select the field(s) that best answer the request. You can read one field or multiple fields at once. @@ -32,8 +32,8 @@ export default class ReadRecordStepExecutor extends RecordStepExecutor { this.warnIfUnsupportedExecutionType( - [ServerStepExecutionTypeEnum.FullyAutomated], - ServerStepExecutionTypeEnum.FullyAutomated, + [StepExecutionMode.FullyAutomated], + StepExecutionMode.FullyAutomated, ); const { stepDefinition: step } = this.context; diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index 0c790f02bf..e22a67468d 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -7,7 +7,6 @@ import type { TriggerActionStepDefinition } from '../types/validated/step-defini import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { ActionNotFoundError, NoActionsError, @@ -15,6 +14,7 @@ import { UnsupportedActionFormError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; +import { StepExecutionMode } from '../types/validated/step-definition'; const TRIGGER_ACTION_SYSTEM_PROMPT = `You are an AI agent triggering an action on a record based on a user request. Select the action to trigger. @@ -32,7 +32,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< protected override buildActivityLogArgs(): CreateActivityLogArgs | null { // Skip when the frontend executes the action itself (non fully-automated mode). // The front logs on its side via the standard agent activity flow. - if (this.context.stepDefinition.executionType !== ServerStepExecutionTypeEnum.FullyAutomated) { + if (this.context.stepDefinition.executionType !== StepExecutionMode.FullyAutomated) { return null; } @@ -63,11 +63,8 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< protected async doExecute(): Promise { this.warnIfUnsupportedExecutionType( - [ - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - ServerStepExecutionTypeEnum.FullyAutomated, - ], - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + StepExecutionMode.AutomatedWithConfirmation, ); // Branch A -- Re-entry after pending execution found in RunStore @@ -125,7 +122,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< // Branch B -- fully automated: executor runs the action itself, so it cannot // handle forms (no UI to fill them). Reject form-bearing actions here. When the // frontend is in the loop (Branch C), it handles the form natively so no check. - if (step.executionType === ServerStepExecutionTypeEnum.FullyAutomated) { + if (step.executionType === StepExecutionMode.FullyAutomated) { const { hasForm } = await this.agentPort.getActionFormInfo( { collection: selectedRecordRef.collectionName, @@ -139,8 +136,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< return this.executeOnExecutor(target); } - // Branch C -- Awaiting confirmation (frontend executes the action, including forms). - // Also covers Manual fallback. + // Branch C -- Awaiting confirmation (frontend executes the action, including forms) await this.context.runStore.saveStepExecution(this.context.runId, { type: 'trigger-action', stepIndex: this.context.stepIndex, 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 0bc6148588..e850a16e02 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -7,7 +7,6 @@ import type { UpdateRecordStepDefinition } from '../types/validated/step-definit import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { ServerStepExecutionTypeEnum } from '../adapters/server-types'; import { FieldNotFoundError, InvalidPreRecordedArgsError, @@ -15,6 +14,7 @@ import { StepStateError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; +import { StepExecutionMode } from '../types/validated/step-definition'; const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. Select the field to update and provide the new value. @@ -125,11 +125,8 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { this.warnIfUnsupportedExecutionType( - [ - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - ServerStepExecutionTypeEnum.FullyAutomated, - ], - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + StepExecutionMode.AutomatedWithConfirmation, ); // Branch A -- Re-entry after pending execution found in RunStore @@ -190,11 +187,11 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor = {}): ConditionStepDefinition { return { @@ -396,7 +395,7 @@ describe('ConditionStepExecutor', () => { makeContext({ model: mockModel.model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), }), ); @@ -415,7 +414,7 @@ describe('ConditionStepExecutor', () => { makeContext({ model: mockModel.model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), incomingPendingData: { selectedOption: 'Approve' }, }), ); @@ -441,7 +440,7 @@ describe('ConditionStepExecutor', () => { makeContext({ model: mockModel.model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), incomingPendingData: { selectedOption: 'Maybe' }, }), ); diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index dec7ca9cf8..475d281e1e 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -6,11 +6,10 @@ import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-ex import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/validated/collection'; import type { LoadRelatedRecordStepDefinition } from '../../src/types/validated/step-definition'; -import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError } from '../../src/errors'; import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; function makeStep( overrides: Partial = {}, @@ -184,7 +183,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -283,7 +282,7 @@ describe('LoadRelatedRecordStepExecutor', () => { customers: hasManySchema, addresses: addressSchema, }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -361,7 +360,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -404,7 +403,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -470,7 +469,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -529,7 +528,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -565,7 +564,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasOneSchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1090,7 +1089,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1122,7 +1121,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1163,7 +1162,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runId: 'run-1', stepIndex: 0, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1220,7 +1219,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1293,7 +1292,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1323,7 +1322,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1438,7 +1437,7 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { const context = makeContext({ - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1585,7 +1584,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1604,7 +1603,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1716,7 +1715,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, runStore, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Order' }, }), }); @@ -1749,7 +1748,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 1 }, }), }); @@ -1787,7 +1786,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 99 }, }), }); @@ -1802,7 +1801,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const { model, bindTools } = makeMockModel({ relationName: 'Orders', reasoning: 'r' }); const context = makeContext({ model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index 5e875535fd..423bd15fb2 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -6,11 +6,10 @@ import type { McpStepDefinition } from '../../src/types/validated/step-definitio import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; -import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { RunStorePortError, StepStateError } from '../../src/errors'; import McpStepExecutor from '../../src/executors/mcp-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; // --------------------------------------------------------------------------- // Helpers @@ -146,7 +145,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -196,7 +195,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -248,7 +247,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -283,7 +282,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -473,7 +472,7 @@ describe('McpStepExecutor', () => { runStore, stepDefinition: makeStep({ mcpServerId: 'id-B', - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, }), }); const executor = new McpStepExecutor(context, [toolA, toolB, toolB2]); @@ -518,7 +517,7 @@ describe('McpStepExecutor', () => { const { model, bindTools } = makeMockModel('tool_a', {}); const context = makeContext({ model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [toolA, toolB]); @@ -542,7 +541,7 @@ describe('McpStepExecutor', () => { model, stepDefinition: makeStep({ mcpServerId: 'forest-connector-42', - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, }), }); const executor = new McpStepExecutor(context, [forestTool]); @@ -676,7 +675,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -735,7 +734,7 @@ describe('McpStepExecutor', () => { const { model } = makeMockModel('send_notification', {}); const context = makeContext({ model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -801,7 +800,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore: mockRunStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -826,7 +825,7 @@ describe('McpStepExecutor', () => { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; const context = makeContext({ model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -1013,7 +1012,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -1046,7 +1045,7 @@ describe('McpStepExecutor', () => { model, logger, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.Manual }), + stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), }); const executor = new McpStepExecutor(context, [tool]); @@ -1060,8 +1059,8 @@ describe('McpStepExecutor', () => { stepIndex: 0, stepType: StepType.Mcp, supportedExecutionTypes: [ - ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - ServerStepExecutionTypeEnum.FullyAutomated, + StepExecutionMode.AutomatedWithConfirmation, + StepExecutionMode.FullyAutomated, ], }), ); @@ -1088,7 +1087,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, logger, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index 1edd5bda71..5d109df404 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -6,11 +6,10 @@ import type { TriggerRecordActionStepExecutionData } from '../../src/types/step- import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; import type { TriggerActionStepDefinition } from '../../src/types/validated/step-definition'; -import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; function makeStep( overrides: Partial = {}, @@ -158,7 +157,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -229,7 +228,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: StepExecutionMode.AutomatedWithConfirmation, }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -479,7 +478,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -550,7 +549,7 @@ describe('TriggerRecordActionStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -580,7 +579,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -612,7 +611,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -634,7 +633,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -662,7 +661,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -692,7 +691,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -799,7 +798,7 @@ describe('TriggerRecordActionStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { const context = makeContext({ - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -819,7 +818,7 @@ describe('TriggerRecordActionStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -933,7 +932,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1051,7 +1050,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { actionDisplayName: 'Send Welcome Email' }, }), }); @@ -1089,7 +1088,7 @@ describe('TriggerRecordActionStepExecutor', () => { const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'r' }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1169,7 +1168,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(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 bdd6c5d3ed..c1e8f431b0 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 @@ -6,11 +6,10 @@ import type { UpdateRecordStepExecutionData } from '../../src/types/step-executi import type { CollectionSchema, RecordRef } from '../../src/types/validated/collection'; import type { UpdateRecordStepDefinition } from '../../src/types/validated/step-definition'; -import { ServerStepExecutionTypeEnum } from '../../src/adapters/server-types'; import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; import SchemaCache from '../../src/schema-cache'; -import { StepType } from '../../src/types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; function makeStep(overrides: Partial = {}): UpdateRecordStepDefinition { return { @@ -154,7 +153,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -487,7 +486,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -516,7 +515,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -556,7 +555,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, workflowPort: makeMockWorkflowPort({ customers: ambiguousSchema }), - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -662,7 +661,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -721,7 +720,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -766,7 +765,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -786,7 +785,7 @@ describe('UpdateRecordStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { const context = makeContext({ - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -811,7 +810,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -830,7 +829,7 @@ describe('UpdateRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -895,7 +894,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -984,7 +983,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status', value: 'active' }, }), }); @@ -1025,7 +1024,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { selectedRecordStepIndex: 0 }, }), }); @@ -1039,7 +1038,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when fieldDisplayName is provided without value', async () => { const context = makeContext({ stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status' }, }), }); @@ -1053,7 +1052,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when value is provided without fieldDisplayName', async () => { const context = makeContext({ stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { value: 'active' }, }), }); @@ -1075,7 +1074,7 @@ describe('UpdateRecordStepExecutor', () => { runStore, workflowPort, stepDefinition: makeStep({ - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Age', value: 42 }, }), }); @@ -1459,7 +1458,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ executionType: ServerStepExecutionTypeEnum.FullyAutomated }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); From c6f5cdf73aa25db892fc816b0fe2f09a77cd7a68 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Fri, 22 May 2026 10:21:02 +0200 Subject: [PATCH 6/7] fix: move the executionType check in the adapter --- .../adapters/forest-server-workflow-port.ts | 2 +- .../adapters/run-to-available-step-mapper.ts | 20 ++-- .../src/adapters/step-definition-mapper.ts | 52 ++++++++-- .../src/executors/base-step-executor.ts | 25 +---- .../src/executors/condition-step-executor.ts | 5 - .../src/executors/guidance-step-executor.ts | 3 - .../load-related-record-step-executor.ts | 5 - .../src/executors/mcp-step-executor.ts | 5 - .../executors/read-record-step-executor.ts | 6 -- .../trigger-record-action-step-executor.ts | 5 - .../executors/update-record-step-executor.ts | 5 - .../src/types/validated/step-definition.ts | 36 +++++++ .../run-to-available-step-mapper.test.ts | 68 +++++++------ .../adapters/step-definition-mapper.test.ts | 99 ++++++++++++------- .../test/executors/mcp-step-executor.test.ts | 62 ------------ 15 files changed, 196 insertions(+), 202 deletions(-) diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 0c0203c9f4..b91629c4a0 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -121,7 +121,7 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); } - const step = toAvailableStepExecution(run); + const step = toAvailableStepExecution(run, this.logger); if (!step) return null; return { step, auth: { forestServerToken: token } }; diff --git a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index ab77712b76..a28dec3a18 100644 --- a/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts +++ b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts @@ -3,6 +3,7 @@ import type { ServerStepHistory, ServerUserProfile, } from './server-types'; +import type { Logger } from '../ports/logger-port'; import type { ConditionStepOutcome, GuidanceStepOutcome, @@ -37,8 +38,8 @@ function toRecordStatus(ctxStatus: unknown): RecordStepOutcome['status'] { // `context` may come from the executor (our StepOutcome, stored verbatim) or the legacy frontend // (free-form). We whitelist known fields per type to avoid leaking legacy ones back to the // orchestrator and to enforce the discriminated-union shape. -function toStepOutcome(s: ServerStepHistory): StepOutcome { - const stepDef = toStepDefinition(s.stepDefinition); +function toStepOutcome(s: ServerStepHistory, logger: Logger): StepOutcome { + const stepDef = toStepDefinition(s.stepDefinition, logger); const outcomeType = stepTypeToOutcomeType(stepDef.type); const ctx = (s.context ?? {}) as Record; @@ -75,9 +76,12 @@ function toStepOutcome(s: ServerStepHistory): StepOutcome { return { type: 'record', ...baseFromCtx, status } satisfies RecordStepOutcome; } -function tryMapStep(s: ServerStepHistory): Step | null { +function tryMapStep(s: ServerStepHistory, logger: Logger): Step | null { try { - return { stepDefinition: toStepDefinition(s.stepDefinition), stepOutcome: toStepOutcome(s) }; + return { + stepDefinition: toStepDefinition(s.stepDefinition, logger), + stepOutcome: toStepOutcome(s, logger), + }; } catch (err) { // Sub-workflow navigation steps (start-sub-workflow, close-sub-workflow) are not // meaningful for AI context — skip them rather than failing the whole run. @@ -89,10 +93,11 @@ function tryMapStep(s: ServerStepHistory): Step | null { function toPreviousSteps( history: ServerStepHistory[], pendingStepIndex: number, + logger: Logger, ): ReadonlyArray { return history .filter(s => s.done && s.stepIndex < pendingStepIndex) - .map(s => tryMapStep(s)) + .map(s => tryMapStep(s, logger)) .filter((s): s is Step => s !== null); } @@ -123,6 +128,7 @@ function toStepUser(runId: number, profile: ServerUserProfile): StepUser { // userProfile) or an unmappable step definition. export default function toAvailableStepExecution( run: ServerHydratedWorkflowRun, + logger: Logger, ): AvailableStepExecution | null { if (!run.collectionName) { throw new InvalidStepDefinitionError( @@ -149,8 +155,8 @@ export default function toAvailableStepExecution( recordId: [run.selectedRecordId], stepIndex: 0, }, - stepDefinition: toStepDefinition(pending.stepDefinition), - previousSteps: toPreviousSteps(run.workflowHistory, pending.stepIndex), + stepDefinition: toStepDefinition(pending.stepDefinition, logger), + previousSteps: toPreviousSteps(run.workflowHistory, pending.stepIndex, logger), user: toStepUser(run.id, run.userProfile), }; diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index bff89ba127..ac41b10137 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -4,11 +4,16 @@ import type { ServerWorkflowStep, ServerWorkflowTask, } from './server-types'; +import type { Logger } from '../ports/logger-port'; import type { ConditionStepDefinition, StepDefinition } from '../types/validated/step-definition'; import { ServerStepExecutionTypeEnum } from './server-types'; import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../errors'; -import { StepExecutionMode, StepType } from '../types/validated/step-definition'; +import { + SUPPORTED_EXECUTION_MODES, + StepExecutionMode, + StepType, +} from '../types/validated/step-definition'; const TASK_TYPE_TO_STEP_TYPE: Record = { 'get-data': StepType.ReadRecord, @@ -32,14 +37,38 @@ function toStepExecutionMode( return executionType === undefined ? undefined : EXECUTION_TYPE_TO_MODE[executionType]; } -function mapTask(task: ServerWorkflowTask): StepDefinition { +// Substitutes the step type's fallback when the configured mode is not supported, logging a +// warning. Returns undefined for legacy workflows that don't specify an executionType — executors +// already treat undefined as the fallback. +function normalizeExecutionType( + stepType: StepType, + executionType: StepExecutionMode | undefined, + logger: Logger, +): StepExecutionMode | undefined { + if (executionType === undefined) return undefined; + const { supported, fallback } = SUPPORTED_EXECUTION_MODES[stepType]; + if (supported.includes(executionType)) return executionType; + + logger.warn( + `Step type "${stepType}" received unsupported executionType=${executionType}; falling back to ${fallback}`, + { stepType, configuredExecutionType: executionType, supportedExecutionTypes: supported }, + ); + + return fallback; +} + +function mapTask(task: ServerWorkflowTask, logger: Logger): StepDefinition { const stepType = TASK_TYPE_TO_STEP_TYPE[task.taskType]; if (!stepType) { throw new InvalidStepDefinitionError(`Unknown taskType: "${task.taskType}"`); } - const executionType = toStepExecutionMode(task.executionType); + const executionType = normalizeExecutionType( + stepType, + toStepExecutionMode(task.executionType), + logger, + ); const base: { prompt: string; executionType?: StepExecutionMode } = { prompt: task.prompt }; if (executionType !== undefined) base.executionType = executionType; @@ -65,7 +94,7 @@ function mapTask(task: ServerWorkflowTask): StepDefinition { } } -function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefinition { +function mapCondition(condition: ServerWorkflowCondition, logger: Logger): ConditionStepDefinition { const options = condition.outgoing .map(t => t.answer ?? t.buttonText) .filter((v): v is string => typeof v === 'string' && v.length > 0); @@ -76,7 +105,11 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti ); } - const executionType = toStepExecutionMode(condition.executionType); + const executionType = normalizeExecutionType( + StepType.Condition, + toStepExecutionMode(condition.executionType), + logger, + ); return { type: StepType.Condition, @@ -89,12 +122,15 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti // Server uses `type:'task' + taskType` for non-condition steps and `outgoing[]` for conditions; // executor uses flat StepDefinition with `options[]`. Unsupported server types // (end/escalation/sub-workflow) throw UnsupportedStepTypeError. -export default function toStepDefinition(serverStep: ServerWorkflowStep): StepDefinition { +export default function toStepDefinition( + serverStep: ServerWorkflowStep, + logger: Logger, +): StepDefinition { switch (serverStep.type) { case 'task': - return mapTask(serverStep); + return mapTask(serverStep, logger); case 'condition': - return mapCondition(serverStep); + return mapCondition(serverStep, logger); case 'end': case 'escalation': case 'start-sub-workflow': diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index f41dfcff58..95c699ef9d 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -6,7 +6,7 @@ import type { StepExecutionResult, } from '../types/execution-context'; import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepDefinition, StepExecutionMode } from '../types/validated/step-definition'; +import type { StepDefinition } from '../types/validated/step-definition'; import type { StepStatus } from '../types/validated/step-outcome'; import type { BaseMessage, @@ -135,29 +135,6 @@ export default abstract class BaseStepExecutor { const args = this.buildActivityLogArgs(); if (!args) return this.runWithTimeout(); diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index f1137e9582..36580d0956 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -60,11 +60,6 @@ export default class ConditionStepExecutor extends BaseStepExecutor { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.Manual, StepExecutionMode.FullyAutomated], - StepExecutionMode.FullyAutomated, - ); - const { stepDefinition: step, incomingPendingData } = this.context; // Manual mode: the user picks the option from the frontend. Wait for their input diff --git a/packages/workflow-executor/src/executors/guidance-step-executor.ts b/packages/workflow-executor/src/executors/guidance-step-executor.ts index c53cb4b988..e63028c122 100644 --- a/packages/workflow-executor/src/executors/guidance-step-executor.ts +++ b/packages/workflow-executor/src/executors/guidance-step-executor.ts @@ -5,12 +5,9 @@ import type { RecordStepStatus } from '../types/validated/step-outcome'; import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; import patchBodySchemas from '../http/pending-data-validators'; -import { StepExecutionMode } from '../types/validated/step-definition'; export default class GuidanceStepExecutor extends BaseStepExecutor { protected async doExecute(): Promise { - this.warnIfUnsupportedExecutionType([StepExecutionMode.Manual], StepExecutionMode.Manual); - const { incomingPendingData } = this.context; if (!incomingPendingData) { diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index c1ca0269bd..d9828de376 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -49,11 +49,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], - StepExecutionMode.AutomatedWithConfirmation, - ); - // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/executors/mcp-step-executor.ts b/packages/workflow-executor/src/executors/mcp-step-executor.ts index b1ddafe146..4477125809 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -70,11 +70,6 @@ export default class McpStepExecutor extends BaseStepExecutor } protected async doExecute(): Promise { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], - StepExecutionMode.AutomatedWithConfirmation, - ); - // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index d3a3c57c5e..950f6c6bc5 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -9,7 +9,6 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoResolvedFieldsError } from '../errors'; import RecordStepExecutor from './record-step-executor'; -import { StepExecutionMode } from '../types/validated/step-definition'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. Select the field(s) that best answer the request. You can read one field or multiple fields at once. @@ -31,11 +30,6 @@ export default class ReadRecordStepExecutor extends RecordStepExecutor { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.FullyAutomated], - StepExecutionMode.FullyAutomated, - ); - const { stepDefinition: step } = this.context; const { preRecordedArgs } = step; const records = await this.getAvailableRecordRefs(); diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index e22a67468d..c10e4fdbdc 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -62,11 +62,6 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< } protected async doExecute(): Promise { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], - StepExecutionMode.AutomatedWithConfirmation, - ); - // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, 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 e850a16e02..abcaa02379 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -124,11 +124,6 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { - this.warnIfUnsupportedExecutionType( - [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], - StepExecutionMode.AutomatedWithConfirmation, - ); - // Branch A -- Re-entry after pending execution found in RunStore const pending = await this.patchAndReloadPendingData( this.context.incomingPendingData, diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 943f1f326d..6b1c3ab855 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -22,6 +22,42 @@ export enum StepExecutionMode { FullyAutomated = 'fully-automated', } +// Declares which execution modes are valid per step type and the fallback to use otherwise. +// Enforced at the adapter boundary so executors can assume the configured mode is supported. +export const SUPPORTED_EXECUTION_MODES: Record< + StepType, + { supported: readonly StepExecutionMode[]; fallback: StepExecutionMode } +> = { + [StepType.Condition]: { + supported: [StepExecutionMode.Manual, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.FullyAutomated, + }, + [StepType.ReadRecord]: { + supported: [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.AutomatedWithConfirmation, + }, + [StepType.UpdateRecord]: { + supported: [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.AutomatedWithConfirmation, + }, + [StepType.TriggerAction]: { + supported: [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.AutomatedWithConfirmation, + }, + [StepType.LoadRelatedRecord]: { + supported: [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.AutomatedWithConfirmation, + }, + [StepType.Mcp]: { + supported: [StepExecutionMode.AutomatedWithConfirmation, StepExecutionMode.FullyAutomated], + fallback: StepExecutionMode.AutomatedWithConfirmation, + }, + [StepType.Guidance]: { + supported: [StepExecutionMode.Manual], + fallback: StepExecutionMode.Manual, + }, +}; + const baseFields = { prompt: z.string().optional(), aiConfigName: z.string().optional(), diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 08bb1b4006..73d080bae0 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -17,6 +17,14 @@ import { import { DomainValidationError, InvalidStepDefinitionError } from '../../src/errors'; import { StepType } from '../../src/types/validated/step-definition'; +const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +beforeEach(() => { + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); +}); + function makeTaskStepDef( overrides: Partial & { taskType?: ServerTaskTypeEnum | string; @@ -41,7 +49,7 @@ function makeConditionStepDef( type: ServerStepTypeEnum.Condition, title: 'Decide', prompt: 'p', - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, automaticCompletion: false, outgoing: [ { stepId: 'a', buttonText: null, answer: 'Yes' }, @@ -95,7 +103,7 @@ describe('toAvailableStepExecution', () => { it('should map a run with a available step to a AvailableStepExecution', () => { const run = makeRun(); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result).toEqual({ runId: '42', @@ -120,7 +128,7 @@ describe('toAvailableStepExecution', () => { it('should stringify the numeric run id', () => { const run = makeRun({ id: 999 }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.runId).toBe('999'); }); @@ -128,7 +136,7 @@ describe('toAvailableStepExecution', () => { it('should wrap selectedRecordId in an array for baseRecordRef', () => { const run = makeRun({ selectedRecordId: 'rec-abc' }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.baseRecordRef.recordId).toEqual(['rec-abc']); }); @@ -136,7 +144,7 @@ describe('toAvailableStepExecution', () => { it('should return null when workflowHistory is empty', () => { const run = makeRun({ workflowHistory: [] }); - expect(toAvailableStepExecution(run)).toBeNull(); + expect(toAvailableStepExecution(run, logger)).toBeNull(); }); it('picks the last step — orchestrator is the source of truth for which step to execute', () => { @@ -148,7 +156,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s2'); expect(result?.stepIndex).toBe(2); @@ -162,18 +170,18 @@ describe('toAvailableStepExecution', () => { taskType: ServerTaskTypeEnum.Guideline, title: 'guidance', prompt: 'follow the guide', - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: ServerStepExecutionTypeEnum.Manual, }), }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepDefinition).toEqual({ type: StepType.Guidance, prompt: 'follow the guide', - executionType: ServerStepExecutionTypeEnum.FullyAutomated, + executionType: ServerStepExecutionTypeEnum.Manual, }); }); @@ -202,7 +210,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps).toHaveLength(2); expect(result?.previousSteps[1].stepOutcome).toEqual({ @@ -231,7 +239,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'record', @@ -258,7 +266,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).not.toHaveProperty('aiReasoning'); expect(result?.previousSteps[0].stepOutcome).not.toHaveProperty('clientData'); @@ -277,7 +285,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'record', @@ -306,7 +314,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'guidance', @@ -334,7 +342,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'guidance', @@ -364,7 +372,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'mcp', @@ -382,7 +390,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s1'); expect(result?.previousSteps).toHaveLength(1); @@ -458,7 +466,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s2'); expect(result?.previousSteps).toHaveLength(1); @@ -481,7 +489,7 @@ describe('toAvailableStepExecution', () => { ], }); - expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run, logger)).toThrow(InvalidStepDefinitionError); }); }); @@ -501,7 +509,7 @@ describe('toAvailableStepExecution', () => { }; const run = makeRun({ userProfile: profile }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.user).toEqual({ id: 5, @@ -537,8 +545,8 @@ describe('toAvailableStepExecution', () => { }, }); - expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); - expect(() => toAvailableStepExecution(run)).toThrow(/renderingId/); + expect(() => toAvailableStepExecution(run, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run, logger)).toThrow(/renderingId/); }); it('should accept renderingId = 0 (valid finite number)', () => { @@ -557,7 +565,7 @@ describe('toAvailableStepExecution', () => { }, }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.user.renderingId).toBe(0); }); @@ -567,8 +575,8 @@ describe('toAvailableStepExecution', () => { it('should throw InvalidStepDefinitionError when collectionName is null', () => { const run = makeRun({ collectionName: null }); - expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); - expect(() => toAvailableStepExecution(run)).toThrow( + expect(() => toAvailableStepExecution(run, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run, logger)).toThrow( 'Run 42 has no collectionName — cannot build baseRecordRef', ); }); @@ -576,8 +584,8 @@ describe('toAvailableStepExecution', () => { it('should throw InvalidStepDefinitionError when collectionId is empty', () => { const run = makeRun({ collectionId: '' }); - expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); - expect(() => toAvailableStepExecution(run)).toThrow( + expect(() => toAvailableStepExecution(run, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run, logger)).toThrow( 'Run 42 has no collectionId — cannot build baseRecordRef', ); }); @@ -597,7 +605,7 @@ describe('toAvailableStepExecution', () => { ], }); - expect(() => toAvailableStepExecution(run)).toThrow(); + expect(() => toAvailableStepExecution(run, logger)).toThrow(); }); it('should throw DomainValidationError when the mapper output violates a zod invariant (empty stepId)', () => { @@ -610,7 +618,7 @@ describe('toAvailableStepExecution', () => { let caught: unknown; try { - toAvailableStepExecution(run); + toAvailableStepExecution(run, logger); } catch (err) { caught = err; } @@ -638,7 +646,7 @@ describe('toAvailableStepExecution', () => { }, }); - expect(() => toAvailableStepExecution(run)).toThrow(DomainValidationError); + expect(() => toAvailableStepExecution(run, logger)).toThrow(DomainValidationError); }); it('should structure DomainValidationError.issues as { path, message } objects', () => { diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index ccbd0d6993..29d89b322f 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -16,10 +16,18 @@ import { } from '../../src/adapters/server-types'; import toStepDefinition from '../../src/adapters/step-definition-mapper'; import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../../src/errors'; -import { StepType } from '../../src/types/validated/step-definition'; +import { StepExecutionMode, StepType } from '../../src/types/validated/step-definition'; const defaultTransition: ServerWorkflowTransition = { stepId: 'next', buttonText: null }; +const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +beforeEach(() => { + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); +}); + function makeTask( overrides: Partial & { taskType?: ServerTaskTypeEnum | string; @@ -45,7 +53,7 @@ function makeCondition( type: ServerStepTypeEnum.Condition, title: 'Test condition', prompt: 'Choose one', - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, automaticCompletion: false, outgoing, ...overrides, @@ -57,7 +65,7 @@ describe('toStepDefinition', () => { it('should map task with get-data taskType to read-record', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.GetData, prompt: 'read it' }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.ReadRecord, prompt: 'read it', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, @@ -67,7 +75,7 @@ describe('toStepDefinition', () => { it('should map task with update-data taskType to update-record', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.UpdateData, prompt: 'update it' }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.UpdateRecord, prompt: 'update it', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, @@ -77,7 +85,7 @@ describe('toStepDefinition', () => { it('should map task with trigger-action taskType to trigger-action', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.TriggerAction, prompt: 'trigger it' }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.TriggerAction, prompt: 'trigger it', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, @@ -90,7 +98,7 @@ describe('toStepDefinition', () => { prompt: 'load it', }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.LoadRelatedRecord, prompt: 'load it', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, @@ -104,7 +112,7 @@ describe('toStepDefinition', () => { mcpServerId: 'mcp-abc', }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.Mcp, prompt: 'run mcp', mcpServerId: 'mcp-abc', @@ -115,7 +123,7 @@ describe('toStepDefinition', () => { it('should map task with mcp-server taskType without mcpServerId', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.McpServer, prompt: 'run mcp' }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.Mcp, prompt: 'run mcp', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, @@ -123,12 +131,16 @@ describe('toStepDefinition', () => { }); it('should map task with guideline taskType to guidance', () => { - const task = makeTask({ taskType: ServerTaskTypeEnum.Guideline, prompt: 'guide them' }); + const task = makeTask({ + taskType: ServerTaskTypeEnum.Guideline, + prompt: 'guide them', + executionType: ServerStepExecutionTypeEnum.Manual, + }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.Guidance, prompt: 'guide them', - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: StepExecutionMode.Manual, }); }); @@ -138,7 +150,7 @@ describe('toStepDefinition', () => { executionType: ServerStepExecutionTypeEnum.FullyAutomated, }); - expect(toStepDefinition(task)).toMatchObject({ + expect(toStepDefinition(task, logger)).toMatchObject({ executionType: ServerStepExecutionTypeEnum.FullyAutomated, }); }); @@ -149,20 +161,35 @@ describe('toStepDefinition', () => { executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); - expect(toStepDefinition(task)).toMatchObject({ + expect(toStepDefinition(task, logger)).toMatchObject({ executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); - it('should preserve executionType=manual', () => { + it('should normalize unsupported executionType=manual on read-record to its fallback and warn', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.GetData, executionType: ServerStepExecutionTypeEnum.Manual, }); - expect(toStepDefinition(task)).toMatchObject({ - executionType: ServerStepExecutionTypeEnum.Manual, + expect(toStepDefinition(task, logger)).toMatchObject({ + executionType: StepExecutionMode.AutomatedWithConfirmation, }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('unsupported executionType=manual'), + expect.objectContaining({ stepType: StepType.ReadRecord }), + ); + }); + + it('should not warn when executionType is supported', () => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.GetData, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + }); + + toStepDefinition(task, logger); + + expect(logger.warn).not.toHaveBeenCalled(); }); it('should omit executionType when undefined on the server step', () => { @@ -170,14 +197,14 @@ describe('toStepDefinition', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.GetData }); delete (task as { executionType?: unknown }).executionType; - expect(toStepDefinition(task)).not.toHaveProperty('executionType'); + expect(toStepDefinition(task, logger)).not.toHaveProperty('executionType'); }); it('should throw InvalidStepDefinitionError for unknown taskType', () => { const task = makeTask({ taskType: 'unknown-task' as ServerTaskTypeEnum }); - expect(() => toStepDefinition(task)).toThrow(InvalidStepDefinitionError); - expect(() => toStepDefinition(task)).toThrow('Unknown taskType: "unknown-task"'); + expect(() => toStepDefinition(task, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(task, logger)).toThrow('Unknown taskType: "unknown-task"'); }); }); @@ -188,11 +215,11 @@ describe('toStepDefinition', () => { { stepId: 's2', buttonText: null, answer: 'No' }, ]); - expect(toStepDefinition(condition)).toEqual({ + expect(toStepDefinition(condition, logger)).toEqual({ type: StepType.Condition, prompt: 'Choose one', options: ['Yes', 'No'], - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: StepExecutionMode.FullyAutomated, }); }); @@ -202,11 +229,11 @@ describe('toStepDefinition', () => { { stepId: 's2', buttonText: 'Reject' }, ]); - expect(toStepDefinition(condition)).toEqual({ + expect(toStepDefinition(condition, logger)).toEqual({ type: StepType.Condition, prompt: 'Choose one', options: ['Approve', 'Reject'], - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: StepExecutionMode.FullyAutomated, }); }); @@ -216,7 +243,7 @@ describe('toStepDefinition', () => { { stepId: 's2', buttonText: 'Btn2', answer: 'Answer2' }, ]); - expect(toStepDefinition(condition)).toMatchObject({ + expect(toStepDefinition(condition, logger)).toMatchObject({ options: ['Answer1', 'Answer2'], }); }); @@ -230,7 +257,7 @@ describe('toStepDefinition', () => { { executionType: ServerStepExecutionTypeEnum.Manual }, ); - expect(toStepDefinition(condition)).toMatchObject({ + expect(toStepDefinition(condition, logger)).toMatchObject({ executionType: ServerStepExecutionTypeEnum.Manual, }); }); @@ -238,8 +265,8 @@ describe('toStepDefinition', () => { it('should throw InvalidStepDefinitionError when fewer than 2 options', () => { const condition = makeCondition([{ stepId: 's1', buttonText: 'Only' }]); - expect(() => toStepDefinition(condition)).toThrow(InvalidStepDefinitionError); - expect(() => toStepDefinition(condition)).toThrow( + expect(() => toStepDefinition(condition, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(condition, logger)).toThrow( 'Condition step requires at least 2 options, got 1', ); }); @@ -247,7 +274,7 @@ describe('toStepDefinition', () => { it('should throw InvalidStepDefinitionError when outgoing is empty', () => { const condition = makeCondition([]); - expect(() => toStepDefinition(condition)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(condition, logger)).toThrow(InvalidStepDefinitionError); }); it('should filter out transitions with no answer and no buttonText', () => { @@ -257,7 +284,7 @@ describe('toStepDefinition', () => { { stepId: 's3', buttonText: null, answer: 'AlsoValid' }, ]); - expect(toStepDefinition(condition)).toMatchObject({ + expect(toStepDefinition(condition, logger)).toMatchObject({ options: ['Valid', 'AlsoValid'], }); }); @@ -274,8 +301,8 @@ describe('toStepDefinition', () => { outgoing: [], }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); - expect(() => toStepDefinition(step)).toThrow( + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow( 'Step type "end" is not supported by the executor', ); }); @@ -291,7 +318,7 @@ describe('toStepDefinition', () => { inboxId: null, }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); it('should throw UnsupportedStepTypeError for start-sub-workflow', () => { @@ -305,7 +332,7 @@ describe('toStepDefinition', () => { workflowId: 'sub-wf', }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); it('should throw UnsupportedStepTypeError for close-sub-workflow', () => { @@ -318,7 +345,7 @@ describe('toStepDefinition', () => { parentWorkflowId: null, }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); }); @@ -326,8 +353,8 @@ describe('toStepDefinition', () => { it('should throw InvalidStepDefinitionError for unknown type', () => { const step = { type: 'mystery', title: 'x' } as unknown as ServerWorkflowStep; - expect(() => toStepDefinition(step)).toThrow(InvalidStepDefinitionError); - expect(() => toStepDefinition(step)).toThrow('Unknown server step type: "mystery"'); + expect(() => toStepDefinition(step, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(step, logger)).toThrow('Unknown server step type: "mystery"'); }); }); }); diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index 423bd15fb2..fe8323a4ce 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -1034,66 +1034,4 @@ describe('McpStepExecutor', () => { }); }); }); - - describe('executionType=Manual (unsupported)', () => { - it('logs a warning and falls back to the AutomatedWithConfirmation flow', async () => { - const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); - const { model } = makeMockModel('send_notification', { message: 'Hello' }); - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; - const runStore = makeMockRunStore(); - const context = makeContext({ - model, - logger, - runStore, - stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), - }); - const executor = new McpStepExecutor(context, [tool]); - - const result = await executor.execute(); - - expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('Step received executionType=manual but does not support it'), - expect.objectContaining({ - runId: 'run-1', - stepId: 'mcp-1', - stepIndex: 0, - stepType: StepType.Mcp, - supportedExecutionTypes: [ - StepExecutionMode.AutomatedWithConfirmation, - StepExecutionMode.FullyAutomated, - ], - }), - ); - // Falls back to the awaiting-input branch — no tool execution. - expect(result.stepOutcome.status).toBe('awaiting-input'); - }); - - it('does not warn when executionType is undefined', async () => { - const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); - const { model } = makeMockModel('send_notification', { message: 'Hello' }); - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; - const context = makeContext({ model, logger, stepDefinition: makeStep() }); - const executor = new McpStepExecutor(context, [tool]); - - await executor.execute(); - - expect(logger.warn).not.toHaveBeenCalled(); - }); - - it('does not warn when executionType is FullyAutomated', async () => { - const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); - const { model } = makeMockModel('send_notification', { message: 'Hello' }); - const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; - const context = makeContext({ - model, - logger, - stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), - }); - const executor = new McpStepExecutor(context, [tool]); - - await executor.execute(); - - expect(logger.warn).not.toHaveBeenCalled(); - }); - }); }); From 09698d90cc6359f13c7bea7eb480b9dd8c9b3b40 Mon Sep 17 00:00:00 2001 From: Enki Pontvianne Date: Fri, 22 May 2026 10:51:32 +0200 Subject: [PATCH 7/7] fix: types --- .../src/adapters/server-types.ts | 40 +++++++++++++++---- .../forest-server-workflow-port.test.ts | 2 +- .../adapters/step-definition-mapper.test.ts | 7 +++- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 4330ff652d..de4960b388 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -56,27 +56,52 @@ export interface ServerWorkflowTaskBase extends ServerWorkflowStepBase { export interface ServerWorkflowTaskGuideline extends ServerWorkflowTaskBase { taskType: ServerTaskTypeEnum.Guideline; + executionType: ServerStepExecutionTypeEnum.Manual; completionType: 'simple' | 'user-input'; inputType?: 'free-text'; automaticCompletion: false; } -export interface ServerWorkflowTaskSimple extends ServerWorkflowTaskBase { - taskType: - | ServerTaskTypeEnum.GetData - | ServerTaskTypeEnum.UpdateData - | ServerTaskTypeEnum.TriggerAction - | ServerTaskTypeEnum.LoadRelatedRecord; +interface ServerWorkflowTaskGetData extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.GetData; + executionType: ServerStepExecutionTypeEnum.FullyAutomated; +} + +interface ServerWorkflowTaskUpdateData extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.UpdateData; + executionType: + | ServerStepExecutionTypeEnum.FullyAutomated + | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; +} + +interface ServerWorkflowTaskTriggerAction extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.TriggerAction; + executionType: + | ServerStepExecutionTypeEnum.FullyAutomated + | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; +} + +interface ServerWorkflowTaskLoadRelatedRecord extends ServerWorkflowTaskBase { + taskType: ServerTaskTypeEnum.LoadRelatedRecord; + executionType: + | ServerStepExecutionTypeEnum.FullyAutomated + | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; } export interface ServerWorkflowTaskMcpServer extends ServerWorkflowTaskBase { taskType: ServerTaskTypeEnum.McpServer; + executionType: + | ServerStepExecutionTypeEnum.FullyAutomated + | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; mcpServerId: string; } export type ServerWorkflowTask = | ServerWorkflowTaskGuideline - | ServerWorkflowTaskSimple + | ServerWorkflowTaskGetData + | ServerWorkflowTaskUpdateData + | ServerWorkflowTaskTriggerAction + | ServerWorkflowTaskLoadRelatedRecord | ServerWorkflowTaskMcpServer; export interface ServerWorkflowEnd extends ServerWorkflowStepBase { @@ -88,6 +113,7 @@ export interface ServerWorkflowEnd extends ServerWorkflowStepBase { export interface ServerWorkflowCondition extends ServerWorkflowStepBase { type: ServerStepTypeEnum.Condition; + executionType: ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated; prompt: string; automaticCompletion: false; } diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index a159b8f43d..c51c04837d 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -20,7 +20,7 @@ function makeConditionStepDef( type: ServerStepTypeEnum.Condition, title: 'Decide', prompt: 'pick one', - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + executionType: ServerStepExecutionTypeEnum.FullyAutomated, automaticCompletion: false, outgoing: [ { stepId: 'next-a', buttonText: 'A', answer: 'Yes' }, diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index 29d89b322f..6ce15c352b 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -157,7 +157,7 @@ describe('toStepDefinition', () => { it('should preserve executionType=automated-with-confirmation', () => { const task = makeTask({ - taskType: ServerTaskTypeEnum.GetData, + taskType: ServerTaskTypeEnum.UpdateData, executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); @@ -166,10 +166,13 @@ describe('toStepDefinition', () => { }); }); + // Casts through `as` because the orchestrator types forbid this combination — the runtime + // normalization is a defensive safety net for wire data the server should not emit. it('should normalize unsupported executionType=manual on read-record to its fallback and warn', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.GetData, - executionType: ServerStepExecutionTypeEnum.Manual, + executionType: + ServerStepExecutionTypeEnum.Manual as ServerStepExecutionTypeEnum.FullyAutomated, }); expect(toStepDefinition(task, logger)).toMatchObject({