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/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/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/adapters/run-to-available-step-mapper.ts b/packages/workflow-executor/src/adapters/run-to-available-step-mapper.ts index 02953a75f1..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; @@ -49,7 +50,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 { @@ -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/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 358456aa10..de4960b388 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -13,61 +13,129 @@ 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; + executionType: ServerStepExecutionTypeEnum.Manual; + completionType: 'simple' | 'user-input'; + inputType?: 'free-text'; + automaticCompletion: false; +} + +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 + | ServerWorkflowTaskGetData + | ServerWorkflowTaskUpdateData + | ServerWorkflowTaskTriggerAction + | ServerWorkflowTaskLoadRelatedRecord + | ServerWorkflowTaskMcpServer; + +export interface ServerWorkflowEnd extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.End; + executionType: ServerStepExecutionTypeEnum.Manual; + automaticCompletion: false; + outgoing: []; +} + +export interface ServerWorkflowCondition extends ServerWorkflowStepBase { + type: ServerStepTypeEnum.Condition; + executionType: ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated; + 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..ac41b10137 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -1,15 +1,21 @@ import type { - ServerTaskType, + ServerTaskTypeEnum, ServerWorkflowCondition, 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 { StepType } from '../types/validated/step-definition'; +import { + SUPPORTED_EXECUTION_MODES, + StepExecutionMode, + 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, @@ -18,22 +24,60 @@ const TASK_TYPE_TO_STEP_TYPE: Record = { guideline: StepType.Guidance, }; -function mapTask(task: ServerWorkflowTask): StepDefinition { +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]; +} + +// 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 base: { prompt: string; automaticExecution?: boolean } = { prompt: task.prompt }; - if (task.automaticExecution !== undefined) base.automaticExecution = task.automaticExecution; + const executionType = normalizeExecutionType( + stepType, + toStepExecutionMode(task.executionType), + logger, + ); + const base: { prompt: string; executionType?: StepExecutionMode } = { prompt: task.prompt }; + if (executionType !== undefined) base.executionType = 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 }; @@ -50,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); @@ -61,22 +105,32 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti ); } + const executionType = normalizeExecutionType( + StepType.Condition, + toStepExecutionMode(condition.executionType), + logger, + ); + return { type: StepType.Condition, prompt: condition.prompt, options, + ...(executionType !== undefined && { executionType }), }; } // 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/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/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 885d680a3f..95c699ef9d 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -128,8 +128,9 @@ 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 === StepExecutionMode.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..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 @@ -16,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. @@ -78,8 +79,8 @@ 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 2723976888..4477125809 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -15,6 +15,7 @@ import { StepStateError, } from '../errors'; import BaseStepExecutor from './base-step-executor'; +import { StepExecutionMode } from '../types/validated/step-definition'; const MCP_TASK_SYSTEM_PROMPT = `You are an AI agent selecting and executing a tool to fulfill a user request. Select the most appropriate tool and fill in its parameters precisely. @@ -87,7 +88,7 @@ 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 === StepExecutionMode.FullyAutomated) { // Branch B -- direct execution return this.executeToolAndPersist(target); } 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..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 @@ -14,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. @@ -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 !== StepExecutionMode.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 === StepExecutionMode.FullyAutomated) { const { hasForm } = await this.agentPort.getActionFormInfo( { collection: selectedRecordRef.collectionName, 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..abcaa02379 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -14,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. @@ -180,8 +181,8 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor): void; + warn(message: string, context: Record): void; info(message: string, context: Record): void; } diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 9ad308a738..6b1c3ab855 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -12,14 +12,57 @@ export enum StepType { Guidance = 'guidance', } +/** + * Domain enum for how a step is executed. Decoupled from the server contract + * (`ServerStepExecutionTypeEnum`) — `step-definition-mapper.ts` is the single translation point. + */ +export enum StepExecutionMode { + Manual = 'manual', + AutomatedWithConfirmation = 'automated-with-confirmation', + 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(), -}; - -const baseRecordFields = { - ...baseFields, - automaticExecution: z.boolean().optional(), + // Use z.enum(EnumObject), not z.nativeEnum — the latter is deprecated in zod 4. + executionType: z.enum(StepExecutionMode).optional(), }; export const ConditionStepDefinitionSchema = z.object({ @@ -30,7 +73,7 @@ export const ConditionStepDefinitionSchema = z.object({ export type ConditionStepDefinition = z.infer; export const ReadRecordStepDefinitionSchema = z.object({ - ...baseRecordFields, + ...baseFields, type: z.literal(StepType.ReadRecord), preRecordedArgs: z .object({ @@ -43,7 +86,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 +100,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 +113,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 +130,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(), }) 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 04ffc40074..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 @@ -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.FullyAutomated, + 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' }, ], - }, + }), }, ], }); @@ -296,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). @@ -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/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/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index f84edcf5a7..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 @@ -2,26 +2,69 @@ 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'; +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; + } = {}, +): 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.FullyAutomated, + 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, }; } @@ -60,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', @@ -72,7 +115,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' }), }); @@ -81,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'); }); @@ -89,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']); }); @@ -97,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', () => { @@ -109,32 +156,33 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s2'); expect(result?.stepIndex).toBe(2); }); - 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.Manual, + }), }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); - 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.Manual, + }); }); describe('previousSteps', () => { @@ -146,34 +194,23 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps).toHaveLength(2); expect(result?.previousSteps[1].stepOutcome).toEqual({ @@ -193,19 +230,16 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'record', @@ -232,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'); @@ -251,7 +285,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'record', @@ -270,19 +304,17 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'guidance', @@ -300,19 +332,17 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'guidance', @@ -331,20 +361,18 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.previousSteps[0].stepOutcome).toEqual({ type: 'mcp', @@ -362,7 +390,7 @@ describe('toAvailableStepExecution', () => { ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s1'); expect(result?.previousSteps).toHaveLength(1); @@ -373,29 +401,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, }, ], @@ -413,19 +457,16 @@ 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 }), ], }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.stepId).toBe('s2'); expect(result?.previousSteps).toHaveLength(1); @@ -439,19 +480,16 @@ 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 }), ], }); - expect(() => toAvailableStepExecution(run)).toThrow(InvalidStepDefinitionError); + expect(() => toAvailableStepExecution(run, logger)).toThrow(InvalidStepDefinitionError); }); }); @@ -471,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, @@ -507,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)', () => { @@ -527,7 +565,7 @@ describe('toAvailableStepExecution', () => { }, }); - const result = toAvailableStepExecution(run); + const result = toAvailableStepExecution(run, logger); expect(result?.user.renderingId).toBe(0); }); @@ -537,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', ); }); @@ -546,18 +584,28 @@ 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', ); }); 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(); + expect(() => toAvailableStepExecution(run, logger)).toThrow(); }); it('should throw DomainValidationError when the mapper output violates a zod invariant (empty stepId)', () => { @@ -570,7 +618,7 @@ describe('toAvailableStepExecution', () => { let caught: unknown; try { - toAvailableStepExecution(run); + toAvailableStepExecution(run, logger); } catch (err) { caught = err; } @@ -598,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 fa9790c500..6ce15c352b 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,43 @@ 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'; +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 = {}): ServerWorkflowTask { +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 +50,11 @@ function makeCondition( overrides: Partial = {}, ): ServerWorkflowCondition { return { - type: 'condition', + type: ServerStepTypeEnum.Condition, title: 'Test condition', prompt: 'Choose one', + executionType: ServerStepExecutionTypeEnum.FullyAutomated, + automaticCompletion: false, outgoing, ...overrides, }; @@ -39,96 +63,151 @@ 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({ + expect(toStepDefinition(task, logger)).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({ + expect(toStepDefinition(task, logger)).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({ + expect(toStepDefinition(task, logger)).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({ + expect(toStepDefinition(task, logger)).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', }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ 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({ + expect(toStepDefinition(task, logger)).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', + executionType: ServerStepExecutionTypeEnum.Manual, + }); - expect(toStepDefinition(task)).toEqual({ + expect(toStepDefinition(task, logger)).toEqual({ type: StepType.Guidance, prompt: 'guide them', + executionType: StepExecutionMode.Manual, }); }); - 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, logger)).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.UpdateData, + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }); - expect(toStepDefinition(task)).toMatchObject({ automaticExecution: false }); + expect(toStepDefinition(task, logger)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + }); }); - it('should omit automaticExecution when undefined on the server step', () => { - const task = makeTask({ taskType: 'get-data' }); + // 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 as ServerStepExecutionTypeEnum.FullyAutomated, + }); + + 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(toStepDefinition(task)).not.toHaveProperty('automaticExecution'); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + 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, logger)).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"'); + expect(() => toStepDefinition(task, logger)).toThrow(InvalidStepDefinitionError); + expect(() => toStepDefinition(task, logger)).toThrow('Unknown taskType: "unknown-task"'); }); }); @@ -139,10 +218,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: StepExecutionMode.FullyAutomated, }); }); @@ -152,10 +232,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: StepExecutionMode.FullyAutomated, }); }); @@ -165,16 +246,30 @@ describe('toStepDefinition', () => { { stepId: 's2', buttonText: 'Btn2', answer: 'Answer2' }, ]); - expect(toStepDefinition(condition)).toMatchObject({ + expect(toStepDefinition(condition, logger)).toMatchObject({ options: ['Answer1', 'Answer2'], }); }); + 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, logger)).toMatchObject({ + executionType: ServerStepExecutionTypeEnum.Manual, + }); + }); + 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', ); }); @@ -182,7 +277,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', () => { @@ -192,7 +287,7 @@ describe('toStepDefinition', () => { { stepId: 's3', buttonText: null, answer: 'AlsoValid' }, ]); - expect(toStepDefinition(condition)).toMatchObject({ + expect(toStepDefinition(condition, logger)).toMatchObject({ options: ['Valid', 'AlsoValid'], }); }); @@ -200,46 +295,60 @@ 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( + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow( 'Step type "end" is not supported by the executor', ); }); 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, }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); 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', }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); 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, }; - expect(() => toStepDefinition(step)).toThrow(UnsupportedStepTypeError); + expect(() => toStepDefinition(step, logger)).toThrow(UnsupportedStepTypeError); }); }); @@ -247,8 +356,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/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/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/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 98761d6400..37ac194862 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -7,7 +7,7 @@ import type { ConditionStepOutcome } from '../../src/types/validated/step-outcom import { RunStorePortError } from '../../src/errors'; import ConditionStepExecutor from '../../src/executors/condition-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 = {}): ConditionStepDefinition { return { @@ -71,7 +71,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' }), @@ -386,4 +386,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: StepExecutionMode.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: StepExecutionMode.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: StepExecutionMode.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/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 16ec0256cb..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 @@ -9,7 +9,7 @@ import type { LoadRelatedRecordStepDefinition } from '../../src/types/validated/ 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 = {}, @@ -144,7 +144,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' }), @@ -174,7 +174,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 +183,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -216,7 +216,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 +282,7 @@ describe('LoadRelatedRecordStepExecutor', () => { customers: hasManySchema, addresses: addressSchema, }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -360,7 +360,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -403,7 +403,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -469,7 +469,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -528,7 +528,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -542,7 +542,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 +564,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasOneSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -587,7 +587,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 +1089,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1121,7 +1121,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1162,7 +1162,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runId: 'run-1', stepIndex: 0, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1219,7 +1219,7 @@ describe('LoadRelatedRecordStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1292,7 +1292,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1312,7 +1312,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')), @@ -1322,7 +1322,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1436,7 +1436,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: StepExecutionMode.FullyAutomated }), + }); const executor = new LoadRelatedRecordStepExecutor(context); const result = await executor.execute(); @@ -1582,7 +1584,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1601,7 +1603,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1713,7 +1715,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Order' }, }), }); @@ -1746,7 +1748,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 1 }, }), }); @@ -1784,7 +1786,7 @@ describe('LoadRelatedRecordStepExecutor', () => { model, agentPort: makeMockAgentPort(relatedData), stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { relationDisplayName: 'Address', selectedRecordIndex: 99 }, }), }); @@ -1799,7 +1801,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const { model, bindTools } = makeMockModel({ relationName: 'Orders', reasoning: 'r' }); const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + 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 96c3d393a9..fe8323a4ce 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -9,7 +9,7 @@ import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; 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 @@ -114,7 +114,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' }), @@ -130,7 +130,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({ @@ -145,7 +145,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -195,7 +195,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -242,12 +242,12 @@ 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, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -282,7 +282,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -303,7 +303,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(); @@ -330,7 +330,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() @@ -470,7 +470,10 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ mcpServerId: 'id-B', automaticExecution: true }), + stepDefinition: makeStep({ + mcpServerId: 'id-B', + executionType: StepExecutionMode.FullyAutomated, + }), }); const executor = new McpStepExecutor(context, [toolA, toolB, toolB2]); @@ -514,7 +517,7 @@ describe('McpStepExecutor', () => { const { model, bindTools } = makeMockModel('tool_a', {}); const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [toolA, toolB]); @@ -538,7 +541,7 @@ describe('McpStepExecutor', () => { model, stepDefinition: makeStep({ mcpServerId: 'forest-connector-42', - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, }), }); const executor = new McpStepExecutor(context, [forestTool]); @@ -596,7 +599,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', @@ -663,7 +666,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() @@ -672,7 +675,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -704,7 +707,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 @@ -731,7 +734,7 @@ describe('McpStepExecutor', () => { const { model } = makeMockModel('send_notification', {}); const context = makeContext({ model, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -797,7 +800,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore: mockRunStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); @@ -819,10 +822,10 @@ 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({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), logger, }); const executor = new McpStepExecutor(context, [tool]); @@ -1009,7 +1012,7 @@ describe('McpStepExecutor', () => { const context = makeContext({ model, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new McpStepExecutor(context, [tool]); 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 46d58934cd..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 @@ -9,7 +9,7 @@ import type { TriggerActionStepDefinition } from '../../src/types/validated/step 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 = {}, @@ -132,7 +132,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' }), @@ -144,7 +144,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 +157,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -187,7 +187,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 +227,9 @@ describe('TriggerRecordActionStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ automaticExecution: false }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -464,7 +466,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 +478,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -496,7 +498,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 +549,7 @@ describe('TriggerRecordActionStepExecutor', () => { agentPort, runStore, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -577,7 +579,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -609,7 +611,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -618,7 +620,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')), @@ -631,7 +633,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -659,7 +661,7 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -689,7 +691,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -795,7 +797,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: StepExecutionMode.FullyAutomated }), + }); const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -814,7 +818,7 @@ describe('TriggerRecordActionStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -928,7 +932,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1046,7 +1050,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { actionDisplayName: 'Send Welcome Email' }, }), }); @@ -1062,7 +1066,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 +1088,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: StepExecutionMode.FullyAutomated }), }); const executor = new TriggerRecordActionStepExecutor(context); @@ -1164,7 +1168,7 @@ describe('TriggerRecordActionStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + 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 afc5f33036..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 @@ -9,7 +9,7 @@ import type { UpdateRecordStepDefinition } from '../../src/types/validated/step- 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 { @@ -129,7 +129,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' }), @@ -141,7 +141,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 +153,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -180,7 +180,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 +479,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: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -515,7 +515,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -555,7 +555,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, workflowPort: makeMockWorkflowPort({ customers: ambiguousSchema }), - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -661,7 +661,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -720,7 +720,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -753,7 +753,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')), @@ -765,7 +765,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, logger, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -784,7 +784,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: StepExecutionMode.FullyAutomated }), + }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -808,7 +810,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -827,7 +829,7 @@ describe('UpdateRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -892,7 +894,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); @@ -981,7 +983,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, runStore, stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status', value: 'active' }, }), }); @@ -997,7 +999,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({ @@ -1022,7 +1024,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { selectedRecordStepIndex: 0 }, }), }); @@ -1036,7 +1038,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when fieldDisplayName is provided without value', async () => { const context = makeContext({ stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Status' }, }), }); @@ -1050,7 +1052,7 @@ describe('UpdateRecordStepExecutor', () => { it('returns error when value is provided without fieldDisplayName', async () => { const context = makeContext({ stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { value: 'active' }, }), }); @@ -1072,7 +1074,7 @@ describe('UpdateRecordStepExecutor', () => { runStore, workflowPort, stepDefinition: makeStep({ - automaticExecution: true, + executionType: StepExecutionMode.FullyAutomated, preRecordedArgs: { fieldDisplayName: 'Age', value: 42 }, }), }); @@ -1456,7 +1458,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticExecution: true }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new UpdateRecordStepExecutor(context); 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();