From c884d37b223bafb0fa0d9830c7b2974550699f96 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 11:37:36 +0100 Subject: [PATCH 01/34] feat(workflow-executor): add TriggerActionStepExecutor with confirmation flow Implements TriggerActionStepExecutor following the UpdateRecordStepExecutor pattern (branches A/B/C, confirmation flow, automaticExecution). - Add TriggerActionStepExecutionData type with executionParams (actionDisplayName + actionName), executionResult ({ success } | { skipped }), and pendingAction - Add NoActionsError for collections with no actions - Implement selectAction via AI tool with displayName enum and technical name hints - resolveAndExecute stores the technical actionName in executionParams for traceability; action result discarded per privacy constraint - Fix buildStepSummary in BaseStepExecutor to include trigger-action pendingAction in prior-step AI context (parity with update-record pendingUpdate) - Export TriggerActionStepExecutor, TriggerActionStepExecutionData, NoActionsError Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/errors.ts | 6 + .../src/executors/base-step-executor.ts | 2 + .../executors/trigger-action-step-executor.ts | 194 +++++ packages/workflow-executor/src/index.ts | 3 + .../src/types/step-execution-data.ts | 14 + .../trigger-action-step-executor.test.ts | 784 ++++++++++++++++++ 6 files changed, 1003 insertions(+) create mode 100644 packages/workflow-executor/src/executors/trigger-action-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 8b872a8932..8651daa917 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -51,3 +51,9 @@ export class NoWritableFieldsError extends WorkflowExecutorError { super(`No writable fields on record from collection "${collectionName}"`); } } + +export class NoActionsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super(`No actions available on collection "${collectionName}"`); + } +} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index bf677691a3..6cbc1d33ad 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -103,6 +103,8 @@ export default abstract class BaseStepExecutor { + async execute(): Promise { + // Branch A -- Re-entry with user confirmation + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); + } + + // Branches B & C -- First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = stepExecutions.find( + (e): e is TriggerActionStepExecutionData => + e.type === 'trigger-action' && e.stepIndex === this.context.stepIndex, + ); + + if (!execution?.pendingAction) { + throw new WorkflowExecutorError('No pending action found for this step'); + } + + if (!this.context.userConfirmed) { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...execution, + executionResult: { skipped: true }, + }); + + return this.buildOutcomeResult('success'); + } + + const { selectedRecordRef, pendingAction } = execution; + const target: TriggerTarget = { + selectedRecordRef, + actionDisplayName: pendingAction.actionDisplayName, + }; + + return this.resolveAndExecute(target, execution); + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + + let target: TriggerTarget; + + try { + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectAction(schema, step.prompt); + target = { selectedRecordRef, actionDisplayName: args.actionDisplayName }; + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return this.buildOutcomeResult('error', error.message); + } + + throw error; + } + + // Branch B -- automaticExecution + if (step.automaticExecution) { + return this.resolveAndExecute(target); + } + + // Branch C -- Awaiting confirmation + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'trigger-action', + stepIndex: this.context.stepIndex, + pendingAction: { actionDisplayName: target.actionDisplayName }, + selectedRecordRef: target.selectedRecordRef, + }); + + return this.buildOutcomeResult('awaiting-input'); + } + + /** + * Resolves the action name, calls executeAction, and persists execution data. + * When `existingExecution` is provided (confirmation flow), it is spread into the + * saved execution to preserve pendingAction for traceability. + */ + private async resolveAndExecute( + target: TriggerTarget, + existingExecution?: TriggerActionStepExecutionData, + ): Promise { + const { selectedRecordRef, actionDisplayName } = target; + let actionName: string; + + try { + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + actionName = this.resolveActionName(schema, actionDisplayName); + // Return value intentionally discarded: action results may contain client data + // and must not leave the client's infrastructure (privacy constraint). + await this.context.agentPort.executeAction(selectedRecordRef.collectionName, actionName, [ + selectedRecordRef.recordId, + ]); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return this.buildOutcomeResult('error', error.message); + } + + throw error; + } + + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { actionDisplayName, actionName }, + executionResult: { success: true }, + selectedRecordRef, + }); + + return this.buildOutcomeResult('success'); + } + + private async selectAction( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ actionDisplayName: string; reasoning: string }> { + const tool = this.buildSelectActionTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(TRIGGER_ACTION_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Trigger the relevant action.'}`), + ]; + + return this.invokeWithTool<{ actionDisplayName: string; reasoning: string }>(messages, tool); + } + + private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool { + if (schema.actions.length === 0) { + throw new NoActionsError(schema.collectionName); + } + + const displayNames = schema.actions.map(a => a.displayName) as [string, ...string[]]; + const technicalNames = schema.actions + .map(a => `${a.displayName} (technical name: ${a.name})`) + .join(', '); + + return new DynamicStructuredTool({ + name: 'select-action', + description: 'Select the action to trigger on the record.', + schema: z.object({ + actionDisplayName: z + .enum(displayNames) + .describe(`The display name of the action to trigger. Available: ${technicalNames}`), + reasoning: z.string().describe('Why this action was chosen'), + }), + func: undefined, + }); + } + + private resolveActionName(schema: CollectionSchema, displayName: string): string { + const action = + schema.actions.find(a => a.displayName === displayName) ?? + schema.actions.find(a => a.name === displayName); + + if (!action) { + throw new WorkflowExecutorError( + `Action "${displayName}" not found in collection "${schema.collectionName}"`, + ); + } + + return action.name; + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 2a1b3df0a8..c382496c5f 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -19,6 +19,7 @@ export type { ConditionStepExecutionData, ReadRecordStepExecutionData, UpdateRecordStepExecutionData, + TriggerActionStepExecutionData, RecordTaskStepExecutionData, LoadRelatedRecordStepExecutionData, ExecutedStepExecutionData, @@ -55,11 +56,13 @@ export { NoReadableFieldsError, NoResolvedFieldsError, NoWritableFieldsError, + NoActionsError, } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; export { default as ReadRecordStepExecutor } from './executors/read-record-step-executor'; export { default as UpdateRecordStepExecutor } from './executors/update-record-step-executor'; +export { default as TriggerActionStepExecutor } from './executors/trigger-action-step-executor'; export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port'; export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; export { default as ExecutorHttpServer } from './http/executor-http-server'; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index f3be7d5e4a..0bdb16a316 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -55,6 +55,18 @@ export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { selectedRecordRef: RecordRef; } +// -- Trigger Action -- + +export interface TriggerActionStepExecutionData extends BaseStepExecutionData { + type: 'trigger-action'; + /** Display name and technical name of the executed action. */ + executionParams?: { actionDisplayName: string; actionName: string }; + executionResult?: { success: true } | { skipped: true }; + /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ + pendingAction?: { actionDisplayName: string }; + selectedRecordRef: RecordRef; +} + // -- Generic AI Task (fallback for untyped steps) -- export interface RecordTaskStepExecutionData extends BaseStepExecutionData { @@ -77,6 +89,7 @@ export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData + | TriggerActionStepExecutionData | RecordTaskStepExecutionData | LoadRelatedRecordStepExecutionData; @@ -84,6 +97,7 @@ export type ExecutedStepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData + | TriggerActionStepExecutionData | RecordTaskStepExecutionData; // TODO: this condition should change when load-related-record gets its own executor diff --git a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts new file mode 100644 index 0000000000..9c369f608f --- /dev/null +++ b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts @@ -0,0 +1,784 @@ +import type { AgentPort } from '../../src/ports/agent-port'; +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { CollectionSchema, RecordRef } from '../../src/types/record'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; +import type { TriggerActionStepExecutionData } from '../../src/types/step-execution-data'; + +import { WorkflowExecutorError } from '../../src/errors'; +import TriggerActionStepExecutor from '../../src/executors/trigger-action-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { + return { + type: StepType.TriggerAction, + prompt: 'Send a welcome email to the customer', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeMockAgentPort(): AgentPort { + return { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn(), + executeAction: jest.fn().mockResolvedValue(undefined), + } as unknown as AgentPort; +} + +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'status', displayName: 'Status', isRelationship: false }, + ], + actions: [ + { name: 'send-welcome-email', displayName: 'Send Welcome Email' }, + { name: 'archive', displayName: 'Archive Customer' }, + ], + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest + .fn() + .mockImplementation((name: string) => + Promise.resolve( + schemasByCollection[name] ?? makeCollectionSchema({ collectionName: name }), + ), + ), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolCallArgs?: Record, toolName = 'select-action') { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'trigger-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + remoteTools: [], + ...overrides, + }; +} + +describe('TriggerActionStepExecutor', () => { + describe('automaticExecution: trigger direct (Branch B)', () => { + it('triggers the action and returns success', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'send-welcome-email', [ + [42], + ]); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + stepIndex: 0, + executionParams: { + actionDisplayName: 'Send Welcome Email', + actionName: 'send-welcome-email', + }, + executionResult: { success: true }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves pendingAction and returns awaiting-input', async () => { + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('triggers the action when user confirms and preserves pendingAction', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'send-welcome-email', [ + [42], + ]); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + executionParams: { + actionDisplayName: 'Send Welcome Email', + actionName: 'send-welcome-email', + }, + executionResult: { success: true }, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the action when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = false; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + }), + ); + }); + }); + + describe('no pending action in confirmation flow (Branch A)', () => { + it('throws when no pending action is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + }); + + it('throws when execution exists but stepIndex does not match', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 5, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + }); + + it('throws when execution exists but pendingAction is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + }); + }); + + describe('NoActionsError', () => { + it('returns error when collection has no actions', async () => { + const schema = makeCollectionSchema({ actions: [] }); + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No actions available on collection "customers"'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('agentPort.executeAction WorkflowExecutorError (Branch B)', () => { + it('returns error when executeAction throws WorkflowExecutorError', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new WorkflowExecutorError('Action not permitted'), + ); + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Action not permitted'); + }); + }); + + describe('agentPort.executeAction WorkflowExecutorError (Branch A)', () => { + it('returns error when executeAction throws WorkflowExecutorError during confirmation', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new WorkflowExecutorError('Action not permitted'), + ); + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Action not permitted'); + }); + }); + + describe('agentPort.executeAction infra error', () => { + it('lets infrastructure errors propagate (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + + it('lets infrastructure errors propagate (Branch A)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + }); + + describe('displayName → name resolution', () => { + it('calls executeAction with the technical name when AI returns a displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns displayName 'Archive Customer', technical name is 'archive' + const mockModel = makeMockModel({ + actionDisplayName: 'Archive Customer', + reasoning: 'User wants to archive', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'archive', [[42]]); + }); + + it('resolves action when AI returns technical name instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns technical name 'archive' instead of display name 'Archive Customer' + const mockModel = makeMockModel({ + actionDisplayName: 'archive', + reasoning: 'fallback to technical name', + }); + const schema = makeCollectionSchema({ + actions: [{ name: 'archive', displayName: 'Archive Customer' }], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'archive', [[42]]); + }); + }); + + describe('multi-record AI selection', () => { + it('uses AI to select among multiple records then selects action', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + actions: [{ name: 'cancel-order', displayName: 'Cancel Order' }], + }); + + // First call: select-record, second call: select-action + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-action', + args: { actionDisplayName: 'Cancel Order', reasoning: 'Cancel the order' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest + .fn() + .mockResolvedValue([ + { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(2); + + const selectTool = bindTools.mock.calls[0][0][0]; + expect(selectTool.name).toBe('select-record'); + + const actionTool = bindTools.mock.calls[1][0][0]; + expect(actionTool.name).toBe('select-action'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingAction: { actionDisplayName: 'Cancel Order' }, + selectedRecordRef: expect.objectContaining({ + recordId: [99], + collectionName: 'orders', + }), + }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('schema caching', () => { + it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { + const workflowPort = makeMockWorkflowPort(); + const context = makeContext({ + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + await executor.execute(); + + // Branch B calls getCollectionSchema in handleFirstCall and again in resolveAndExecute + // but the cache should prevent the second network call + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('AI malformed/missing tool call', () => { + it('returns error on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'select-action', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'AI returned a malformed tool call for "select-action": JSON parse error', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns no tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('resolveActionName failure', () => { + it('returns error when action display name stored in pendingAction no longer exists (Branch A)', async () => { + const schema = makeCollectionSchema({ + actions: [{ name: 'archive', displayName: 'Archive Customer' }], + }); + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Deleted Action' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const userConfirmed = true; + const context = makeContext({ runStore, workflowPort, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'Action "Deleted Action" not found in collection "customers"', + ); + }); + }); + + describe('RunStore error propagation', () => { + it('lets getStepExecutions errors propagate (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('DB timeout'); + }); + + it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userConfirmed = false; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + + it('lets saveStepExecution errors propagate when saving awaiting-input (Branch C)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + + it('lets saveStepExecution errors propagate after successful executeAction (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + + it('lets saveStepExecution errors propagate after successful executeAction (Branch A confirmed)', async () => { + const execution: TriggerActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { actionDisplayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerActionStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new TriggerActionStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[mockModel.invoke.mock.calls.length - 1][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Trigger the relevant action.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in select-action messages', async () => { + const mockModel = makeMockModel({ + actionDisplayName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new TriggerActionStepExecutor({ + ...context, + stepId: 'trigger-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps message + system prompt + collection info + human message = 4 + expect(messages).toHaveLength(4); + expect(messages[0].content).toContain('Should we proceed?'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('triggering an action'); + }); + }); +}); From b28c467bbf5e3ba89863d01f545bf176073af9ed Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 12:00:28 +0100 Subject: [PATCH 02/34] refactor(workflow-executor): propagate actionName through ActionTarget and pendingAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve actionName once in handleFirstCall and store it in pendingAction, so resolveAndExecute receives it directly via ActionTarget without re-fetching the schema. Rename TriggerTarget → ActionTarget for consistency with English naming conventions. Co-Authored-By: Claude Sonnet 4.6 --- .../executors/trigger-action-step-executor.ts | 20 ++++---- .../src/types/step-execution-data.ts | 2 +- .../trigger-action-step-executor.test.ts | 50 ++++--------------- 3 files changed, 21 insertions(+), 51 deletions(-) diff --git a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-action-step-executor.ts index c315d4df83..fa1564574b 100644 --- a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-action-step-executor.ts @@ -18,9 +18,10 @@ Important rules: - Final answer is definitive, you won't receive any other input from the user. - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; -interface TriggerTarget { +interface ActionTarget { selectedRecordRef: RecordRef; actionDisplayName: string; + actionName: string; } export default class TriggerActionStepExecutor extends BaseStepExecutor { @@ -55,9 +56,10 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { - const { selectedRecordRef, actionDisplayName } = target; - let actionName: string; + const { selectedRecordRef, actionDisplayName, actionName } = target; try { - const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - actionName = this.resolveActionName(schema, actionDisplayName); // Return value intentionally discarded: action results may contain client data // and must not leave the client's infrastructure (privacy constraint). await this.context.agentPort.executeAction(selectedRecordRef.collectionName, actionName, [ diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 0bdb16a316..0bacd2c99c 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -63,7 +63,7 @@ export interface TriggerActionStepExecutionData extends BaseStepExecutionData { executionParams?: { actionDisplayName: string; actionName: string }; executionResult?: { success: true } | { skipped: true }; /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ - pendingAction?: { actionDisplayName: string }; + pendingAction?: { actionDisplayName: string; actionName: string }; selectedRecordRef: RecordRef; } diff --git a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts index 9c369f608f..3d9a52cead 100644 --- a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts @@ -175,7 +175,7 @@ describe('TriggerActionStepExecutor', () => { expect.objectContaining({ type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -191,7 +191,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -216,7 +216,7 @@ describe('TriggerActionStepExecutor', () => { actionName: 'send-welcome-email', }, executionResult: { success: true }, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, }), ); }); @@ -228,7 +228,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -246,7 +246,7 @@ describe('TriggerActionStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, }), ); }); @@ -355,7 +355,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -396,7 +396,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -525,7 +525,7 @@ describe('TriggerActionStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - pendingAction: { actionDisplayName: 'Cancel Order' }, + pendingAction: { actionDisplayName: 'Cancel Order', actionName: 'cancel-order' }, selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', @@ -562,8 +562,6 @@ describe('TriggerActionStepExecutor', () => { await executor.execute(); - // Branch B calls getCollectionSchema in handleFirstCall and again in resolveAndExecute - // but the cache should prevent the second network call expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); }); }); @@ -611,34 +609,6 @@ describe('TriggerActionStepExecutor', () => { }); }); - describe('resolveActionName failure', () => { - it('returns error when action display name stored in pendingAction no longer exists (Branch A)', async () => { - const schema = makeCollectionSchema({ - actions: [{ name: 'archive', displayName: 'Archive Customer' }], - }); - const execution: TriggerActionStepExecutionData = { - type: 'trigger-action', - stepIndex: 0, - pendingAction: { actionDisplayName: 'Deleted Action' }, - selectedRecordRef: makeRecordRef(), - }; - const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([execution]), - }); - const workflowPort = makeMockWorkflowPort({ customers: schema }); - const userConfirmed = true; - const context = makeContext({ runStore, workflowPort, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); - - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - 'Action "Deleted Action" not found in collection "customers"', - ); - }); - }); - describe('RunStore error propagation', () => { it('lets getStepExecutions errors propagate (Branch A)', async () => { const runStore = makeMockRunStore({ @@ -655,7 +625,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -696,7 +666,7 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ From 21e4bacb41672abe6af12fc6dc731ac9b68c3c47 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 12:40:52 +0100 Subject: [PATCH 03/34] refactor(workflow-executor): extract ActionRef and FieldRef, align Ref shapes to { name, displayName } MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract ActionRef { name, displayName } from inline types in TriggerActionStepExecutionData - Extract FieldRef { name, displayName } replacing FieldReadBase in ReadRecord types - UpdateRecordStepExecutionData executionParams/pendingUpdate now use FieldRef & { value } - Rename actionDisplayName/actionName → displayName/name, fieldDisplayName/fieldName → displayName/name - Move resolveFieldName to handleFirstCall (no re-resolution in resolveAndUpdate) - Add missing tests: resolveActionName not-found path, saveStepExecution not-called assertions, trigger-action pendingAction in buildStepSummary - Export ActionRef, FieldRef, NoActionsError from index; update CLAUDE.md Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/CLAUDE.md | 5 +- .../executors/read-record-step-executor.ts | 6 +- .../executors/trigger-action-step-executor.ts | 28 ++--- .../executors/update-record-step-executor.ts | 30 +++-- packages/workflow-executor/src/index.ts | 2 + .../src/types/step-execution-data.ts | 28 +++-- .../test/executors/base-step-executor.test.ts | 39 +++++- .../read-record-step-executor.test.ts | 20 ++-- .../trigger-action-step-executor.test.ts | 113 ++++++++++++++---- .../update-record-step-executor.test.ts | 57 +++------ 10 files changed, 207 insertions(+), 121 deletions(-) diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index f0318c97bd..2a006024ba 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -42,7 +42,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor ── ``` src/ -├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError, NoActionsError ├── runner.ts # Runner class — main entry point (start/stop/triggerPoll, HTTP server wiring) ├── types/ # Core type definitions (@draft) │ ├── step-definition.ts # StepType enum + step definition interfaces @@ -61,7 +61,8 @@ src/ │ ├── base-step-executor.ts # Abstract base class (context injection + shared helpers) │ ├── condition-step-executor.ts # AI-powered condition step (chooses among options) │ ├── read-record-step-executor.ts # AI-powered record field reading step -│ └── update-record-step-executor.ts # AI-powered record field update step (with confirmation flow) +│ ├── update-record-step-executor.ts # AI-powered record field update step (with confirmation flow) +│ └── trigger-action-step-executor.ts # AI-powered action trigger step (with confirmation flow) ├── http/ # HTTP server (optional, for frontend data access) │ └── executor-http-server.ts # Koa server: GET /runs/:runId, POST /runs/:runId/trigger └── index.ts # Barrel exports diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 23eb651b9f..3b9a607438 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -56,7 +56,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor f.fieldName) }, + executionParams: { fieldNames: fieldResults.map(f => f.name) }, executionResult: { fields: fieldResults }, selectedRecordRef, }); @@ -119,11 +119,11 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { const field = this.findField(schema, name); - if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; + if (!field) return { error: `Field not found: ${name}`, name, displayName: name }; return { value: values[field.fieldName], - fieldName: field.fieldName, + name: field.fieldName, displayName: field.displayName, }; }); diff --git a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-action-step-executor.ts index fa1564574b..33c08ead4e 100644 --- a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-action-step-executor.ts @@ -1,7 +1,7 @@ import type { StepExecutionResult } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; -import type { TriggerActionStepExecutionData } from '../types/step-execution-data'; +import type { ActionRef, TriggerActionStepExecutionData } from '../types/step-execution-data'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; @@ -18,10 +18,8 @@ Important rules: - Final answer is definitive, you won't receive any other input from the user. - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; -interface ActionTarget { +interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; - actionDisplayName: string; - actionName: string; } export default class TriggerActionStepExecutor extends BaseStepExecutor { @@ -58,8 +56,8 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { - const { selectedRecordRef, actionDisplayName, actionName } = target; + const { selectedRecordRef, displayName, name } = target; try { // Return value intentionally discarded: action results may contain client data // and must not leave the client's infrastructure (privacy constraint). - await this.context.agentPort.executeAction(selectedRecordRef.collectionName, actionName, [ + await this.context.agentPort.executeAction(selectedRecordRef.collectionName, name, [ selectedRecordRef.recordId, ]); } catch (error) { @@ -130,7 +128,7 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { + ): Promise<{ displayName: string; reasoning: string }> { const tool = this.buildSelectActionTool(schema); const messages = [ ...(await this.buildPreviousStepsMessages()), @@ -152,7 +150,7 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor(messages, tool); + return this.invokeWithTool<{ displayName: string; reasoning: string }>(messages, tool); } private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool { @@ -169,7 +167,7 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { - const { selectedRecordRef, fieldDisplayName, value } = target; + const { selectedRecordRef, displayName, name, value } = target; let updated: { values: Record }; try { - const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const fieldName = this.resolveFieldName(schema, fieldDisplayName); updated = await this.context.agentPort.updateRecord( selectedRecordRef.collectionName, selectedRecordRef.recordId, - { [fieldName]: value }, + { [name]: value }, ); } catch (error) { if (error instanceof WorkflowExecutorError) { @@ -132,7 +140,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor } | { skipped: true }; /** AI-selected field and value awaiting user confirmation. Used in the confirmation flow only. */ - pendingUpdate?: { - fieldDisplayName: string; - value: string; - }; + pendingUpdate?: FieldRef & { value: string }; selectedRecordRef: RecordRef; } // -- Trigger Action -- +export interface ActionRef { + name: string; + displayName: string; +} + export interface TriggerActionStepExecutionData extends BaseStepExecutionData { type: 'trigger-action'; /** Display name and technical name of the executed action. */ - executionParams?: { actionDisplayName: string; actionName: string }; + executionParams?: ActionRef; executionResult?: { success: true } | { skipped: true }; /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ - pendingAction?: { actionDisplayName: string; actionName: string }; + pendingAction?: ActionRef; selectedRecordRef: RecordRef; } 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 45f5bc9c42..738107ac83 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -373,7 +373,7 @@ describe('BaseStepExecutor', () => { { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, }, ]), @@ -385,11 +385,46 @@ describe('BaseStepExecutor', () => { .then(msgs => msgs[0]?.content ?? ''); expect(result).toContain('Pending:'); - expect(result).toContain('"fieldDisplayName":"Status"'); + expect(result).toContain('"displayName":"Status"'); expect(result).toContain('"value":"active"'); expect(result).not.toContain('Input:'); }); + it('includes pending action in summary for trigger-action step', async () => { + const executor = new TestableExecutor( + makeContext({ + previousSteps: [ + { + stepDefinition: { type: StepType.TriggerAction, prompt: 'Archive the customer' }, + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'awaiting-input', + }, + }, + ], + runStore: makeMockRunStore([ + { + type: 'trigger-action', + stepIndex: 0, + pendingAction: { displayName: 'Archive Customer', name: 'archive' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Archive Customer"'); + expect(result).toContain('"name":"archive"'); + expect(result).not.toContain('Input:'); + }); + it('shows "(no prompt)" when step has no prompt', async () => { const entry = makeHistoryEntry({ stepIndex: 0 }); entry.stepDefinition.prompt = undefined; 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 35ae85925b..21d7559f30 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 @@ -135,7 +135,7 @@ describe('ReadRecordStepExecutor', () => { stepIndex: 0, executionParams: { fieldNames: ['email'] }, executionResult: { - fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, }), ); @@ -158,8 +158,8 @@ describe('ReadRecordStepExecutor', () => { executionParams: { fieldNames: ['email', 'name'] }, executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, + { value: 'John Doe', name: 'name', displayName: 'Full Name' }, ], }, }), @@ -182,7 +182,7 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ executionParams: { fieldNames: ['name'] }, executionResult: { - fields: [{ value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }], + fields: [{ value: 'John Doe', name: 'name', displayName: 'Full Name' }], }, }), ); @@ -247,10 +247,10 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, { error: 'Field not found: nonexistent', - fieldName: 'nonexistent', + name: 'nonexistent', displayName: 'nonexistent', }, ], @@ -392,7 +392,7 @@ describe('ReadRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { - fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, selectedRecordRef: expect.objectContaining({ recordId: [42], @@ -459,7 +459,7 @@ describe('ReadRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { - fields: [{ value: 150, fieldName: 'total', displayName: 'Total' }], + fields: [{ value: 150, name: 'total', displayName: 'Total' }], }, selectedRecordRef: expect.objectContaining({ recordId: [99], @@ -769,8 +769,8 @@ describe('ReadRecordStepExecutor', () => { executionParams: { fieldNames: ['email', 'name'] }, executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, + { value: 'John Doe', name: 'name', displayName: 'Full Name' }, ], }, selectedRecordRef: { diff --git a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts index 3d9a52cead..d3294953ac 100644 --- a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts @@ -100,7 +100,7 @@ function makeContext( baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'User requested welcome email', }).model, agentPort: makeMockAgentPort(), @@ -117,7 +117,7 @@ describe('TriggerActionStepExecutor', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'User requested welcome email', }); const runStore = makeMockRunStore(); @@ -141,8 +141,8 @@ describe('TriggerActionStepExecutor', () => { type: 'trigger-action', stepIndex: 0, executionParams: { - actionDisplayName: 'Send Welcome Email', - actionName: 'send-welcome-email', + displayName: 'Send Welcome Email', + name: 'send-welcome-email', }, executionResult: { success: true }, selectedRecordRef: expect.objectContaining({ @@ -157,7 +157,7 @@ describe('TriggerActionStepExecutor', () => { describe('without automaticExecution: awaiting-input (Branch C)', () => { it('saves pendingAction and returns awaiting-input', async () => { const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'User requested welcome email', }); const runStore = makeMockRunStore(); @@ -175,7 +175,10 @@ describe('TriggerActionStepExecutor', () => { expect.objectContaining({ type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -191,7 +194,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -212,11 +218,14 @@ describe('TriggerActionStepExecutor', () => { expect.objectContaining({ type: 'trigger-action', executionParams: { - actionDisplayName: 'Send Welcome Email', - actionName: 'send-welcome-email', + displayName: 'Send Welcome Email', + name: 'send-welcome-email', }, executionResult: { success: true }, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, }), ); }); @@ -228,7 +237,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -246,7 +258,10 @@ describe('TriggerActionStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, }), ); }); @@ -270,7 +285,7 @@ describe('TriggerActionStepExecutor', () => { { type: 'trigger-action', stepIndex: 5, - pendingAction: { actionDisplayName: 'Send Welcome Email' }, + pendingAction: { displayName: 'Send Welcome Email' }, selectedRecordRef: makeRecordRef(), }, ]), @@ -304,7 +319,7 @@ describe('TriggerActionStepExecutor', () => { it('returns error when collection has no actions', async () => { const schema = makeCollectionSchema({ actions: [] }); const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore(); @@ -320,6 +335,38 @@ describe('TriggerActionStepExecutor', () => { }); }); + describe('resolveActionName failure', () => { + it('returns error when AI returns an action name not found in the schema', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + displayName: 'NonExistentAction', + reasoning: 'hallucinated', + }); + const schema = makeCollectionSchema({ + actions: [{ name: 'archive', displayName: 'Archive Customer' }], + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'Action "NonExistentAction" not found in collection "customers"', + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + describe('agentPort.executeAction WorkflowExecutorError (Branch B)', () => { it('returns error when executeAction throws WorkflowExecutorError', async () => { const agentPort = makeMockAgentPort(); @@ -327,7 +374,7 @@ describe('TriggerActionStepExecutor', () => { new WorkflowExecutorError('Action not permitted'), ); const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore(); @@ -343,6 +390,7 @@ describe('TriggerActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Action not permitted'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -355,7 +403,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -369,6 +420,7 @@ describe('TriggerActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Action not permitted'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -377,7 +429,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'test', }); const context = makeContext({ @@ -396,7 +448,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -415,7 +470,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); // AI returns displayName 'Archive Customer', technical name is 'archive' const mockModel = makeMockModel({ - actionDisplayName: 'Archive Customer', + displayName: 'Archive Customer', reasoning: 'User wants to archive', }); const context = makeContext({ @@ -435,7 +490,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); // AI returns technical name 'archive' instead of display name 'Archive Customer' const mockModel = makeMockModel({ - actionDisplayName: 'archive', + displayName: 'archive', reasoning: 'fallback to technical name', }); const schema = makeCollectionSchema({ @@ -488,7 +543,7 @@ describe('TriggerActionStepExecutor', () => { tool_calls: [ { name: 'select-action', - args: { actionDisplayName: 'Cancel Order', reasoning: 'Cancel the order' }, + args: { displayName: 'Cancel Order', reasoning: 'Cancel the order' }, id: 'call_2', }, ], @@ -525,7 +580,7 @@ describe('TriggerActionStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - pendingAction: { actionDisplayName: 'Cancel Order', actionName: 'cancel-order' }, + pendingAction: { displayName: 'Cancel Order', name: 'cancel-order' }, selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', @@ -625,7 +680,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -666,7 +724,10 @@ describe('TriggerActionStepExecutor', () => { const execution: TriggerActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { actionDisplayName: 'Send Welcome Email', actionName: 'send-welcome-email' }, + pendingAction: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -684,7 +745,7 @@ describe('TriggerActionStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'test', }); const context = makeContext({ @@ -704,7 +765,7 @@ describe('TriggerActionStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in select-action messages', async () => { const mockModel = makeMockModel({ - actionDisplayName: 'Send Welcome Email', + displayName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore({ 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 1d2ace3713..578aa715f2 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 @@ -146,7 +146,7 @@ describe('UpdateRecordStepExecutor', () => { expect.objectContaining({ type: 'update-record', stepIndex: 0, - executionParams: { fieldDisplayName: 'Status', value: 'active' }, + executionParams: { displayName: 'Status', name: 'status', value: 'active' }, executionResult: { updatedValues }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', @@ -179,7 +179,7 @@ describe('UpdateRecordStepExecutor', () => { expect.objectContaining({ type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -196,7 +196,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -214,9 +214,9 @@ describe('UpdateRecordStepExecutor', () => { 'run-1', expect.objectContaining({ type: 'update-record', - executionParams: { fieldDisplayName: 'Status', value: 'active' }, + executionParams: { displayName: 'Status', name: 'status', value: 'active' }, executionResult: { updatedValues }, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, }), ); }); @@ -228,7 +228,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -246,7 +246,7 @@ describe('UpdateRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, }), ); }); @@ -270,7 +270,7 @@ describe('UpdateRecordStepExecutor', () => { { type: 'update-record', stepIndex: 5, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }, ]), @@ -370,7 +370,11 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - pendingUpdate: { fieldDisplayName: 'Order Status', value: 'shipped' }, + pendingUpdate: { + displayName: 'Order Status', + name: 'status', + value: 'shipped', + }, selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', @@ -406,32 +410,6 @@ describe('UpdateRecordStepExecutor', () => { }); describe('resolveFieldName failure', () => { - it('returns error when field is not found during confirmation (Branch A)', async () => { - const schema = makeCollectionSchema({ - fields: [{ fieldName: 'email', displayName: 'Email', isRelationship: false }], - }); - const execution: UpdateRecordStepExecutionData = { - type: 'update-record', - stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'NonExistentField', value: 'active' }, - selectedRecordRef: makeRecordRef(), - }; - const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([execution]), - }); - const workflowPort = makeMockWorkflowPort({ customers: schema }); - const userConfirmed = true; - const context = makeContext({ runStore, workflowPort, userConfirmed }); - const executor = new UpdateRecordStepExecutor(context); - - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - 'Field "NonExistentField" not found in collection "customers"', - ); - }); - it('returns error when field is not found during automaticExecution (Branch B)', async () => { // AI returns a display name that doesn't match any field in the schema const mockModel = makeMockModel({ @@ -564,7 +542,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -606,7 +584,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -666,8 +644,7 @@ describe('UpdateRecordStepExecutor', () => { await executor.execute(); - // Branch B calls getCollectionSchema in handleFirstCall and again in resolveAndUpdate - // but the cache should prevent the second network call + // resolveFieldName is called in handleFirstCall, so getCollectionSchema is only fetched once expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); }); }); @@ -688,7 +665,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ From e4ad28f50afcd033c97647e8834bf47d2c77bdcd Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 12:50:47 +0100 Subject: [PATCH 04/34] =?UTF-8?q?refactor(workflow-executor):=20rename=20f?= =?UTF-8?q?ieldNames=E2=86=92fieldDisplayNames=20and=20store=20display=20n?= =?UTF-8?q?ames=20in=20executionParams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../executors/read-record-step-executor.ts | 12 ++-- .../src/types/step-execution-data.ts | 2 +- .../read-record-step-executor.test.ts | 68 +++++++++++-------- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 3b9a607438..7a35c569bc 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -56,7 +56,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor f.name) }, + executionParams: { fieldDisplayNames: fieldResults.map(f => f.displayName) }, executionResult: { fields: fieldResults }, selectedRecordRef, }); @@ -78,9 +78,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor(messages, tool); + const args = await this.invokeWithTool<{ fieldDisplayNames: string[] }>(messages, tool); - return args.fieldNames; + return args.fieldDisplayNames; } private buildReadFieldTool(schema: CollectionSchema): DynamicStructuredTool { @@ -99,7 +99,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor, schema: CollectionSchema, - fieldNames: string[], + fieldDisplayNames: string[], ): FieldReadResult[] { - return fieldNames.map(name => { + return fieldDisplayNames.map(name => { const field = this.findField(schema, name); if (!field) return { error: `Field not found: ${name}`, name, displayName: name }; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 3eed9ea69c..41772726ac 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -37,7 +37,7 @@ export type FieldReadResult = FieldReadSuccess | FieldReadError; export interface ReadRecordStepExecutionData extends BaseStepExecutionData { type: 'read-record'; - executionParams: { fieldNames: string[] }; + executionParams: { fieldDisplayNames: string[] }; executionResult: { fields: FieldReadResult[] }; selectedRecordRef: RecordRef; } 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 21d7559f30..8effaf210a 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 @@ -107,7 +107,7 @@ function makeContext( stepIndex: 0, baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), - model: makeMockModel({ fieldNames: ['email'] }).model, + model: makeMockModel({ fieldDisplayNames: ['email'] }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), @@ -120,7 +120,7 @@ function makeContext( describe('ReadRecordStepExecutor', () => { describe('single record, single field', () => { it('reads a single field and returns success', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -133,7 +133,7 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ type: 'read-record', stepIndex: 0, - executionParams: { fieldNames: ['email'] }, + executionParams: { fieldDisplayNames: ['Email'] }, executionResult: { fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, @@ -144,7 +144,7 @@ describe('ReadRecordStepExecutor', () => { describe('single record, multiple fields', () => { it('reads multiple fields in one call and returns success', async () => { - const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -155,7 +155,7 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldNames: ['email', 'name'] }, + executionParams: { fieldDisplayNames: ['Email', 'Full Name'] }, executionResult: { fields: [ { value: 'john@example.com', name: 'email', displayName: 'Email' }, @@ -169,7 +169,7 @@ describe('ReadRecordStepExecutor', () => { describe('field resolution by displayName', () => { it('resolves fields by displayName', async () => { - const mockModel = makeMockModel({ fieldNames: ['Full Name'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['Full Name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -180,7 +180,7 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldNames: ['name'] }, + executionParams: { fieldDisplayNames: ['Full Name'] }, executionResult: { fields: [{ value: 'John Doe', name: 'name', displayName: 'Full Name' }], }, @@ -191,7 +191,7 @@ describe('ReadRecordStepExecutor', () => { describe('getRecord receives resolved field names', () => { it('passes resolved field names (not display names) to getRecord', async () => { - const mockModel = makeMockModel({ fieldNames: ['Full Name', 'Email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['Full Name', 'Email'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -203,7 +203,7 @@ describe('ReadRecordStepExecutor', () => { }); it('passes only resolved field names when some fields are unresolved', async () => { - const mockModel = makeMockModel({ fieldNames: ['Email', 'nonexistent'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['Email', 'nonexistent'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -215,7 +215,7 @@ describe('ReadRecordStepExecutor', () => { }); it('returns error when no fields can be resolved', async () => { - const mockModel = makeMockModel({ fieldNames: ['nonexistent', 'unknown'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['nonexistent', 'unknown'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -234,7 +234,7 @@ describe('ReadRecordStepExecutor', () => { describe('field not found', () => { it('returns error per field without failing globally', async () => { - const mockModel = makeMockModel({ fieldNames: ['email', 'nonexistent'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'nonexistent'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -262,7 +262,7 @@ describe('ReadRecordStepExecutor', () => { describe('relationship fields excluded', () => { it('excludes relationship fields from tool schema', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -273,16 +273,16 @@ describe('ReadRecordStepExecutor', () => { expect(tool.name).toBe('read-selected-record-fields'); // Valid field names (displayNames and fieldNames) should be accepted in an array - expect(tool.schema.parse({ fieldNames: ['Email'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldNames: ['Full Name'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldNames: ['email'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldNames: ['email', 'name'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldDisplayNames: ['Email'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldDisplayNames: ['Full Name'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldDisplayNames: ['email'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldDisplayNames: ['email', 'name'] })).toBeTruthy(); // Schema accepts any strings (per-field errors handled in readFieldValues, ISO frontend) - expect(tool.schema.parse({ fieldNames: ['Orders'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldDisplayNames: ['Orders'] })).toBeTruthy(); // But rejects non-array values - expect(() => tool.schema.parse({ fieldNames: 'email' })).toThrow(); + expect(() => tool.schema.parse({ fieldDisplayNames: 'email' })).toThrow(); }); }); @@ -300,7 +300,7 @@ describe('ReadRecordStepExecutor', () => { const schema = makeCollectionSchema({ fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], }); - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore(); const workflowPort = makeMockWorkflowPort({ customers: schema }); const context = makeContext({ model: mockModel.model, runStore, workflowPort }); @@ -347,7 +347,7 @@ describe('ReadRecordStepExecutor', () => { tool_calls: [ { name: 'read-selected-record-fields', - args: { fieldNames: ['email'] }, + args: { fieldDisplayNames: ['email'] }, id: 'call_2', }, ], @@ -429,7 +429,11 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-fields', args: { fieldNames: ['total'] }, id: 'call_2' }, + { + name: 'read-selected-record-fields', + args: { fieldDisplayNames: ['total'] }, + id: 'call_2', + }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -496,7 +500,11 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-fields', args: { fieldNames: ['email'] }, id: 'call_2' }, + { + name: 'read-selected-record-fields', + args: { fieldDisplayNames: ['email'] }, + id: 'call_2', + }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -582,7 +590,7 @@ describe('ReadRecordStepExecutor', () => { (agentPort.getRecord as jest.Mock).mockRejectedValue( new RecordNotFoundError('customers', '42'), ); - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore, agentPort }); const executor = new ReadRecordStepExecutor(context); @@ -597,7 +605,7 @@ describe('ReadRecordStepExecutor', () => { it('lets infrastructure errors propagate', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new ReadRecordStepExecutor(context); @@ -663,7 +671,7 @@ describe('ReadRecordStepExecutor', () => { describe('RunStore error propagation', () => { it('lets saveStepExecution errors propagate', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), }); @@ -674,7 +682,7 @@ describe('ReadRecordStepExecutor', () => { }); it('lets getStepExecutions errors propagate', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('Connection lost')), }); @@ -687,7 +695,7 @@ describe('ReadRecordStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in read-field messages', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -735,7 +743,7 @@ describe('ReadRecordStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ prompt: undefined }), @@ -752,7 +760,7 @@ describe('ReadRecordStepExecutor', () => { describe('saveStepExecution arguments', () => { it('saves executionParams, executionResult, and selectedRecord', async () => { - const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, @@ -766,7 +774,7 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { type: 'read-record', stepIndex: 3, - executionParams: { fieldNames: ['email', 'name'] }, + executionParams: { fieldDisplayNames: ['Email', 'Full Name'] }, executionResult: { fields: [ { value: 'john@example.com', name: 'email', displayName: 'Email' }, From 30687a872c16970e1fa2561ed1c7266eb7f2fce6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 12:54:00 +0100 Subject: [PATCH 05/34] refactor(workflow-executor): use FieldRef[] in ReadRecord executionParams for consistency Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/read-record-step-executor.ts | 2 +- .../src/types/step-execution-data.ts | 2 +- .../read-record-step-executor.test.ts | 18 ++++++++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 7a35c569bc..37fc29f990 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -56,7 +56,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor f.displayName) }, + executionParams: { fields: fieldResults.map(({ name, displayName }) => ({ name, displayName })) }, executionResult: { fields: fieldResults }, selectedRecordRef, }); diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 41772726ac..b7245d1b0f 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -37,7 +37,7 @@ export type FieldReadResult = FieldReadSuccess | FieldReadError; export interface ReadRecordStepExecutionData extends BaseStepExecutionData { type: 'read-record'; - executionParams: { fieldDisplayNames: string[] }; + executionParams: { fields: FieldRef[] }; executionResult: { fields: FieldReadResult[] }; selectedRecordRef: RecordRef; } 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 8effaf210a..8baf61d6e1 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 @@ -133,7 +133,7 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ type: 'read-record', stepIndex: 0, - executionParams: { fieldDisplayNames: ['Email'] }, + executionParams: { fields: [{ name: 'email', displayName: 'Email' }] }, executionResult: { fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, @@ -155,7 +155,12 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldDisplayNames: ['Email', 'Full Name'] }, + executionParams: { + fields: [ + { name: 'email', displayName: 'Email' }, + { name: 'name', displayName: 'Full Name' }, + ], + }, executionResult: { fields: [ { value: 'john@example.com', name: 'email', displayName: 'Email' }, @@ -180,7 +185,7 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldDisplayNames: ['Full Name'] }, + executionParams: { fields: [{ name: 'name', displayName: 'Full Name' }] }, executionResult: { fields: [{ value: 'John Doe', name: 'name', displayName: 'Full Name' }], }, @@ -774,7 +779,12 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { type: 'read-record', stepIndex: 3, - executionParams: { fieldDisplayNames: ['Email', 'Full Name'] }, + executionParams: { + fields: [ + { name: 'email', displayName: 'Email' }, + { name: 'name', displayName: 'Full Name' }, + ], + }, executionResult: { fields: [ { value: 'john@example.com', name: 'email', displayName: 'Email' }, From fd6250ed8342d8ef3ac1f9a26d80f7e0a3b322ba Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 14:11:01 +0100 Subject: [PATCH 06/34] style(workflow-executor): fix prettier formatting in read-record-step-executor Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/read-record-step-executor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 37fc29f990..17a85cf62e 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -56,7 +56,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor ({ name, displayName })) }, + executionParams: { + fields: fieldResults.map(({ name, displayName }) => ({ name, displayName })), + }, executionResult: { fields: fieldResults }, selectedRecordRef, }); From b0bb7c8b74b2dd6e44e53ad41873565ed374d3f1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 14:20:09 +0100 Subject: [PATCH 07/34] refactor(workflow-executor): rename Zod schema keys to remove "display" wording Use fieldNames instead of fieldDisplayNames and actionName instead of displayName in tool schemas exposed to the AI, to avoid confusion between property names and their semantics (values remain display names). Co-Authored-By: Claude Sonnet 4.6 --- .../executors/read-record-step-executor.ts | 6 +-- .../executors/trigger-action-step-executor.ts | 12 ++--- .../read-record-step-executor.test.ts | 52 +++++++++---------- .../trigger-action-step-executor.test.ts | 24 ++++----- 4 files changed, 47 insertions(+), 47 deletions(-) diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 17a85cf62e..896ff73ff7 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -80,9 +80,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor(messages, tool); + const args = await this.invokeWithTool<{ fieldNames: string[] }>(messages, tool); - return args.fieldDisplayNames; + return args.fieldNames; } private buildReadFieldTool(schema: CollectionSchema): DynamicStructuredTool { @@ -101,7 +101,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { + ): Promise<{ actionName: string; reasoning: string }> { const tool = this.buildSelectActionTool(schema); const messages = [ ...(await this.buildPreviousStepsMessages()), @@ -150,7 +150,7 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor(messages, tool); + return this.invokeWithTool<{ actionName: string; reasoning: string }>(messages, tool); } private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool { @@ -167,9 +167,9 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { describe('single record, single field', () => { it('reads a single field and returns success', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -144,7 +144,7 @@ describe('ReadRecordStepExecutor', () => { describe('single record, multiple fields', () => { it('reads multiple fields in one call and returns success', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -174,7 +174,7 @@ describe('ReadRecordStepExecutor', () => { describe('field resolution by displayName', () => { it('resolves fields by displayName', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['Full Name'] }); + const mockModel = makeMockModel({ fieldNames: ['Full Name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -196,7 +196,7 @@ describe('ReadRecordStepExecutor', () => { describe('getRecord receives resolved field names', () => { it('passes resolved field names (not display names) to getRecord', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['Full Name', 'Email'] }); + const mockModel = makeMockModel({ fieldNames: ['Full Name', 'Email'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -208,7 +208,7 @@ describe('ReadRecordStepExecutor', () => { }); it('passes only resolved field names when some fields are unresolved', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['Email', 'nonexistent'] }); + const mockModel = makeMockModel({ fieldNames: ['Email', 'nonexistent'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -220,7 +220,7 @@ describe('ReadRecordStepExecutor', () => { }); it('returns error when no fields can be resolved', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['nonexistent', 'unknown'] }); + const mockModel = makeMockModel({ fieldNames: ['nonexistent', 'unknown'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); @@ -239,7 +239,7 @@ describe('ReadRecordStepExecutor', () => { describe('field not found', () => { it('returns error per field without failing globally', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'nonexistent'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'nonexistent'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -267,7 +267,7 @@ describe('ReadRecordStepExecutor', () => { describe('relationship fields excluded', () => { it('excludes relationship fields from tool schema', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -278,16 +278,16 @@ describe('ReadRecordStepExecutor', () => { expect(tool.name).toBe('read-selected-record-fields'); // Valid field names (displayNames and fieldNames) should be accepted in an array - expect(tool.schema.parse({ fieldDisplayNames: ['Email'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldDisplayNames: ['Full Name'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldDisplayNames: ['email'] })).toBeTruthy(); - expect(tool.schema.parse({ fieldDisplayNames: ['email', 'name'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldNames: ['Email'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldNames: ['Full Name'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldNames: ['email'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldNames: ['email', 'name'] })).toBeTruthy(); // Schema accepts any strings (per-field errors handled in readFieldValues, ISO frontend) - expect(tool.schema.parse({ fieldDisplayNames: ['Orders'] })).toBeTruthy(); + expect(tool.schema.parse({ fieldNames: ['Orders'] })).toBeTruthy(); // But rejects non-array values - expect(() => tool.schema.parse({ fieldDisplayNames: 'email' })).toThrow(); + expect(() => tool.schema.parse({ fieldNames: 'email' })).toThrow(); }); }); @@ -305,7 +305,7 @@ describe('ReadRecordStepExecutor', () => { const schema = makeCollectionSchema({ fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], }); - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const workflowPort = makeMockWorkflowPort({ customers: schema }); const context = makeContext({ model: mockModel.model, runStore, workflowPort }); @@ -352,7 +352,7 @@ describe('ReadRecordStepExecutor', () => { tool_calls: [ { name: 'read-selected-record-fields', - args: { fieldDisplayNames: ['email'] }, + args: { fieldNames: ['email'] }, id: 'call_2', }, ], @@ -436,7 +436,7 @@ describe('ReadRecordStepExecutor', () => { tool_calls: [ { name: 'read-selected-record-fields', - args: { fieldDisplayNames: ['total'] }, + args: { fieldNames: ['total'] }, id: 'call_2', }, ], @@ -507,7 +507,7 @@ describe('ReadRecordStepExecutor', () => { tool_calls: [ { name: 'read-selected-record-fields', - args: { fieldDisplayNames: ['email'] }, + args: { fieldNames: ['email'] }, id: 'call_2', }, ], @@ -595,7 +595,7 @@ describe('ReadRecordStepExecutor', () => { (agentPort.getRecord as jest.Mock).mockRejectedValue( new RecordNotFoundError('customers', '42'), ); - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore, agentPort }); const executor = new ReadRecordStepExecutor(context); @@ -610,7 +610,7 @@ describe('ReadRecordStepExecutor', () => { it('lets infrastructure errors propagate', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new ReadRecordStepExecutor(context); @@ -676,7 +676,7 @@ describe('ReadRecordStepExecutor', () => { describe('RunStore error propagation', () => { it('lets saveStepExecution errors propagate', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), }); @@ -687,7 +687,7 @@ describe('ReadRecordStepExecutor', () => { }); it('lets getStepExecutions errors propagate', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('Connection lost')), }); @@ -700,7 +700,7 @@ describe('ReadRecordStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in read-field messages', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -748,7 +748,7 @@ describe('ReadRecordStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email'] }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ prompt: undefined }), @@ -765,7 +765,7 @@ describe('ReadRecordStepExecutor', () => { describe('saveStepExecution arguments', () => { it('saves executionParams, executionResult, and selectedRecord', async () => { - const mockModel = makeMockModel({ fieldDisplayNames: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, diff --git a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts index d3294953ac..b151e4eb0e 100644 --- a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts @@ -100,7 +100,7 @@ function makeContext( baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), model: makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', }).model, agentPort: makeMockAgentPort(), @@ -117,7 +117,7 @@ describe('TriggerActionStepExecutor', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', }); const runStore = makeMockRunStore(); @@ -157,7 +157,7 @@ describe('TriggerActionStepExecutor', () => { describe('without automaticExecution: awaiting-input (Branch C)', () => { it('saves pendingAction and returns awaiting-input', async () => { const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', }); const runStore = makeMockRunStore(); @@ -319,7 +319,7 @@ describe('TriggerActionStepExecutor', () => { it('returns error when collection has no actions', async () => { const schema = makeCollectionSchema({ actions: [] }); const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore(); @@ -339,7 +339,7 @@ describe('TriggerActionStepExecutor', () => { it('returns error when AI returns an action name not found in the schema', async () => { const agentPort = makeMockAgentPort(); const mockModel = makeMockModel({ - displayName: 'NonExistentAction', + actionName: 'NonExistentAction', reasoning: 'hallucinated', }); const schema = makeCollectionSchema({ @@ -374,7 +374,7 @@ describe('TriggerActionStepExecutor', () => { new WorkflowExecutorError('Action not permitted'), ); const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore(); @@ -429,7 +429,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'test', }); const context = makeContext({ @@ -470,7 +470,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); // AI returns displayName 'Archive Customer', technical name is 'archive' const mockModel = makeMockModel({ - displayName: 'Archive Customer', + actionName: 'Archive Customer', reasoning: 'User wants to archive', }); const context = makeContext({ @@ -490,7 +490,7 @@ describe('TriggerActionStepExecutor', () => { const agentPort = makeMockAgentPort(); // AI returns technical name 'archive' instead of display name 'Archive Customer' const mockModel = makeMockModel({ - displayName: 'archive', + actionName: 'archive', reasoning: 'fallback to technical name', }); const schema = makeCollectionSchema({ @@ -543,7 +543,7 @@ describe('TriggerActionStepExecutor', () => { tool_calls: [ { name: 'select-action', - args: { displayName: 'Cancel Order', reasoning: 'Cancel the order' }, + args: { actionName: 'Cancel Order', reasoning: 'Cancel the order' }, id: 'call_2', }, ], @@ -745,7 +745,7 @@ describe('TriggerActionStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'test', }); const context = makeContext({ @@ -765,7 +765,7 @@ describe('TriggerActionStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in select-action messages', async () => { const mockModel = makeMockModel({ - displayName: 'Send Welcome Email', + actionName: 'Send Welcome Email', reasoning: 'test', }); const runStore = makeMockRunStore({ From 9b9a1dffb7e5ec0a201787f0255250e21ae232ea Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 15:16:24 +0100 Subject: [PATCH 08/34] refactor(workflow-executor): introduce executor hierarchy and centralize pending state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make buildOutcomeResult abstract on BaseStepExecutor; each branch owns its outcome shape - Add RecordTaskStepExecutor intermediate class implementing buildOutcomeResult for 'record-task' - Add pendingData to BaseStepExecutionData, replacing pendingUpdate/pendingAction per-type fields; each executor sets it when saving, base class reads it directly with no type discrimination - Rename TriggerActionStepExecutor → TriggerRecordActionStepExecutor for naming consistency with ReadRecord/UpdateRecord Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/base-step-executor.ts | 38 ++++---- .../src/executors/condition-step-executor.ts | 50 ++++------ .../executors/read-record-step-executor.ts | 14 +-- .../executors/record-task-step-executor.ts | 23 +++++ ...=> trigger-record-action-step-executor.ts} | 38 +++----- .../executors/update-record-step-executor.ts | 34 +++---- packages/workflow-executor/src/index.ts | 4 +- .../src/types/step-execution-data.ts | 12 ++- .../test/executors/base-step-executor.test.ts | 9 +- ...igger-record-action-step-executor.test.ts} | 94 +++++++++---------- .../update-record-step-executor.test.ts | 20 ++-- 11 files changed, 165 insertions(+), 171 deletions(-) create mode 100644 packages/workflow-executor/src/executors/record-task-step-executor.ts rename packages/workflow-executor/src/executors/{trigger-action-step-executor.ts => trigger-record-action-step-executor.ts} (84%) rename packages/workflow-executor/test/executors/{trigger-action-step-executor.test.ts => trigger-record-action-step-executor.test.ts} (90%) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 6cbc1d33ad..e7f72cff3a 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -5,7 +5,7 @@ import type { LoadRelatedRecordStepExecutionData, StepExecutionData, } from '../types/step-execution-data'; -import type { StepOutcome } from '../types/step-outcome'; +import type { StepOutcome, StepStatus } from '../types/step-outcome'; import type { AIMessage, BaseMessage } from '@langchain/core/messages'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -39,24 +39,22 @@ export default abstract class BaseStepExecutor { + protected buildOutcomeResult(outcome: { + status: ConditionStepStatus; + error?: string; + selectedOption?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'condition', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + ...outcome, + }, + }; + } + async execute(): Promise { const { stepDefinition: step } = this.context; @@ -68,19 +83,7 @@ export default class ConditionStepExecutor extends BaseStepExecutor(messages, tool); } catch (error) { - if (error instanceof WorkflowExecutorError) { - return { - stepOutcome: { - type: 'condition', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'error', - error: error.message, - }, - }; - } - - throw error; + return this.buildErrorOutcomeOrThrow(error); } const { option: selectedOption, reasoning } = args; @@ -93,24 +96,9 @@ export default class ConditionStepExecutor extends BaseStepExecutor { +export default class ReadRecordStepExecutor extends RecordTaskStepExecutor { async execute(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); @@ -46,11 +46,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor extends BaseStepExecutor { + protected buildOutcomeResult(outcome: { + status: RecordTaskStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + ...outcome, + }, + }; + } +} diff --git a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts similarity index 84% rename from packages/workflow-executor/src/executors/trigger-action-step-executor.ts rename to packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts index cd957c9ee8..0fcd9f562a 100644 --- a/packages/workflow-executor/src/executors/trigger-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -1,14 +1,14 @@ import type { StepExecutionResult } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; -import type { ActionRef, TriggerActionStepExecutionData } from '../types/step-execution-data'; +import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { NoActionsError, WorkflowExecutorError } from '../errors'; -import BaseStepExecutor from './base-step-executor'; +import RecordTaskStepExecutor from './record-task-step-executor'; 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. @@ -22,7 +22,7 @@ interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; } -export default class TriggerActionStepExecutor extends BaseStepExecutor { +export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecutor { async execute(): Promise { // Branch A -- Re-entry with user confirmation if (this.context.userConfirmed !== undefined) { @@ -36,11 +36,11 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); const execution = stepExecutions.find( - (e): e is TriggerActionStepExecutionData => + (e): e is TriggerRecordActionStepExecutionData => e.type === 'trigger-action' && e.stepIndex === this.context.stepIndex, ); - if (!execution?.pendingAction) { + if (!execution?.pendingData) { throw new WorkflowExecutorError('No pending action found for this step'); } @@ -50,14 +50,14 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { const { selectedRecordRef, displayName, name } = target; @@ -117,11 +113,7 @@ export default class TriggerActionStepExecutor extends BaseStepExecutor { +export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { async execute(): Promise { // Branch A -- Re-entry with user confirmation if (this.context.userConfirmed !== undefined) { @@ -41,7 +41,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor } | { skipped: true }; /** AI-selected field and value awaiting user confirmation. Used in the confirmation flow only. */ - pendingUpdate?: FieldRef & { value: string }; + pendingData?: FieldRef & { value: string }; selectedRecordRef: RecordRef; } @@ -61,13 +63,13 @@ export interface ActionRef { displayName: string; } -export interface TriggerActionStepExecutionData extends BaseStepExecutionData { +export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionData { type: 'trigger-action'; /** Display name and technical name of the executed action. */ executionParams?: ActionRef; executionResult?: { success: true } | { skipped: true }; /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ - pendingAction?: ActionRef; + pendingData?: ActionRef; selectedRecordRef: RecordRef; } @@ -93,7 +95,7 @@ export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData - | TriggerActionStepExecutionData + | TriggerRecordActionStepExecutionData | RecordTaskStepExecutionData | LoadRelatedRecordStepExecutionData; @@ -101,7 +103,7 @@ export type ExecutedStepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData - | TriggerActionStepExecutionData + | TriggerRecordActionStepExecutionData | RecordTaskStepExecutionData; // TODO: this condition should change when load-related-record gets its own executor 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 738107ac83..76ba1c64cb 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; import type { RecordRef } from '../../src/types/record'; @@ -17,6 +18,10 @@ class TestableExecutor extends BaseStepExecutor { throw new Error('not used'); } + protected buildOutcomeResult(): StepExecutionResult { + throw new Error('not used'); + } + override buildPreviousStepsMessages(): Promise { return super.buildPreviousStepsMessages(); } @@ -373,7 +378,7 @@ describe('BaseStepExecutor', () => { { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, }, ]), @@ -408,7 +413,7 @@ describe('BaseStepExecutor', () => { { type: 'trigger-action', stepIndex: 0, - pendingAction: { displayName: 'Archive Customer', name: 'archive' }, + pendingData: { displayName: 'Archive Customer', name: 'archive' }, selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, }, ]), diff --git a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts similarity index 90% rename from packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts rename to packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts index b151e4eb0e..d5bd9eb871 100644 --- a/packages/workflow-executor/test/executors/trigger-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -4,10 +4,10 @@ import type { WorkflowPort } from '../../src/ports/workflow-port'; import type { ExecutionContext } from '../../src/types/execution'; import type { CollectionSchema, RecordRef } from '../../src/types/record'; import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; -import type { TriggerActionStepExecutionData } from '../../src/types/step-execution-data'; +import type { TriggerRecordActionStepExecutionData } from '../../src/types/step-execution-data'; import { WorkflowExecutorError } from '../../src/errors'; -import TriggerActionStepExecutor from '../../src/executors/trigger-action-step-executor'; +import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; import { StepType } from '../../src/types/step-definition'; function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { @@ -112,7 +112,7 @@ function makeContext( }; } -describe('TriggerActionStepExecutor', () => { +describe('TriggerRecordActionStepExecutor', () => { describe('automaticExecution: trigger direct (Branch B)', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); @@ -127,7 +127,7 @@ describe('TriggerActionStepExecutor', () => { runStore, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -165,7 +165,7 @@ describe('TriggerActionStepExecutor', () => { model: mockModel.model, runStore, }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -175,7 +175,7 @@ describe('TriggerActionStepExecutor', () => { expect.objectContaining({ type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -191,10 +191,10 @@ describe('TriggerActionStepExecutor', () => { describe('confirmation accepted (Branch A)', () => { it('triggers the action when user confirms and preserves pendingAction', async () => { const agentPort = makeMockAgentPort(); - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -205,7 +205,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ agentPort, runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -222,7 +222,7 @@ describe('TriggerActionStepExecutor', () => { name: 'send-welcome-email', }, executionResult: { success: true }, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -234,10 +234,10 @@ describe('TriggerActionStepExecutor', () => { describe('confirmation rejected (Branch A)', () => { it('skips the action when user rejects', async () => { const agentPort = makeMockAgentPort(); - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -248,7 +248,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = false; const context = makeContext({ agentPort, runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -258,7 +258,7 @@ describe('TriggerActionStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -274,7 +274,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); }); @@ -285,14 +285,14 @@ describe('TriggerActionStepExecutor', () => { { type: 'trigger-action', stepIndex: 5, - pendingAction: { displayName: 'Send Welcome Email' }, + pendingData: { displayName: 'Send Welcome Email' }, selectedRecordRef: makeRecordRef(), }, ]), }); const userConfirmed = true; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); }); @@ -309,7 +309,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); }); @@ -325,7 +325,7 @@ describe('TriggerActionStepExecutor', () => { const runStore = makeMockRunStore(); const workflowPort = makeMockWorkflowPort({ customers: schema }); const context = makeContext({ model: mockModel.model, runStore, workflowPort }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -354,7 +354,7 @@ describe('TriggerActionStepExecutor', () => { workflowPort, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -384,7 +384,7 @@ describe('TriggerActionStepExecutor', () => { runStore, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -400,10 +400,10 @@ describe('TriggerActionStepExecutor', () => { (agentPort.executeAction as jest.Mock).mockRejectedValue( new WorkflowExecutorError('Action not permitted'), ); - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -414,7 +414,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ agentPort, runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -437,7 +437,7 @@ describe('TriggerActionStepExecutor', () => { agentPort, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Connection refused'); }); @@ -445,10 +445,10 @@ describe('TriggerActionStepExecutor', () => { it('lets infrastructure errors propagate (Branch A)', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -459,7 +459,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ agentPort, runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Connection refused'); }); @@ -478,7 +478,7 @@ describe('TriggerActionStepExecutor', () => { agentPort, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -503,7 +503,7 @@ describe('TriggerActionStepExecutor', () => { workflowPort, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -564,7 +564,7 @@ describe('TriggerActionStepExecutor', () => { }); const agentPort = makeMockAgentPort(); const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -580,7 +580,7 @@ describe('TriggerActionStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - pendingAction: { displayName: 'Cancel Order', name: 'cancel-order' }, + pendingData: { displayName: 'Cancel Order', name: 'cancel-order' }, selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', @@ -593,7 +593,7 @@ describe('TriggerActionStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -613,7 +613,7 @@ describe('TriggerActionStepExecutor', () => { workflowPort, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await executor.execute(); @@ -635,7 +635,7 @@ describe('TriggerActionStepExecutor', () => { model: { bindTools } as unknown as ExecutionContext['model'], runStore, }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -654,7 +654,7 @@ describe('TriggerActionStepExecutor', () => { model: { bindTools } as unknown as ExecutionContext['model'], runStore, }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); const result = await executor.execute(); @@ -671,16 +671,16 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('DB timeout'); }); it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -692,7 +692,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = false; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Disk full'); }); @@ -702,7 +702,7 @@ describe('TriggerActionStepExecutor', () => { saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); const context = makeContext({ runStore }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Disk full'); }); @@ -715,16 +715,16 @@ describe('TriggerActionStepExecutor', () => { runStore, stepDefinition: makeStep({ automaticExecution: true }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Disk full'); }); it('lets saveStepExecution errors propagate after successful executeAction (Branch A confirmed)', async () => { - const execution: TriggerActionStepExecutionData = { + const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, - pendingAction: { + pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, @@ -736,7 +736,7 @@ describe('TriggerActionStepExecutor', () => { }); const userConfirmed = true; const context = makeContext({ runStore, userConfirmed }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Disk full'); }); @@ -752,7 +752,7 @@ describe('TriggerActionStepExecutor', () => { model: mockModel.model, stepDefinition: makeStep({ prompt: undefined }), }); - const executor = new TriggerActionStepExecutor(context); + const executor = new TriggerRecordActionStepExecutor(context); await executor.execute(); @@ -796,7 +796,7 @@ describe('TriggerActionStepExecutor', () => { }, ], }); - const executor = new TriggerActionStepExecutor({ + const executor = new TriggerRecordActionStepExecutor({ ...context, stepId: 'trigger-2', stepIndex: 1, 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 578aa715f2..8b4ebda1ca 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 @@ -179,7 +179,7 @@ describe('UpdateRecordStepExecutor', () => { expect.objectContaining({ type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -196,7 +196,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -216,7 +216,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'update-record', executionParams: { displayName: 'Status', name: 'status', value: 'active' }, executionResult: { updatedValues }, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, }), ); }); @@ -228,7 +228,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -246,7 +246,7 @@ describe('UpdateRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, }), ); }); @@ -270,7 +270,7 @@ describe('UpdateRecordStepExecutor', () => { { type: 'update-record', stepIndex: 5, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }, ]), @@ -370,7 +370,7 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - pendingUpdate: { + pendingData: { displayName: 'Order Status', name: 'status', value: 'shipped', @@ -542,7 +542,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -584,7 +584,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -665,7 +665,7 @@ describe('UpdateRecordStepExecutor', () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - pendingUpdate: { displayName: 'Status', name: 'status', value: 'active' }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ From c8b924792b72afbf67ce323c7dde42dbc47f1591 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 16:19:38 +0100 Subject: [PATCH 09/34] refactor(workflow-executor): apply Template Method pattern and consolidate error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce doExecute() as the abstract hook; execute() in the base class wraps it with the single try-catch that converts WorkflowExecutorErrors to error outcomes and rethrows infrastructure errors — removing all try-catch boilerplate from subclasses - Inline the former buildErrorOutcomeOrThrow helper directly into execute(), it no longer needs to be a named method - Add StepPersistenceError (extends WorkflowExecutorError) for post-side-effect persistence failures; these are now consistently converted to error outcomes - Extract handleConfirmationFlow into RecordTaskStepExecutor to DRY the confirmation pattern shared by update-record and trigger-record-action - Split the !execution?.pendingData guard into two distinct WorkflowExecutorErrors for clearer debug messages - Export BaseStepStatus from step-outcome; remove pendingData from BaseStepExecutionData (declared only on the concrete types that need it) - Fix extractToolCallArgs to throw MalformedToolCallError when args is null/undefined Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/errors.ts | 13 +++ .../src/executors/base-step-executor.ts | 40 ++++--- .../src/executors/condition-step-executor.ts | 30 ++--- .../executors/read-record-step-executor.ts | 44 +++----- .../executors/record-task-step-executor.ts | 44 ++++++++ .../trigger-record-action-step-executor.ts | 95 +++++++--------- .../executors/update-record-step-executor.ts | 105 +++++++----------- .../src/types/step-execution-data.ts | 4 +- .../src/types/step-outcome.ts | 2 +- .../test/executors/base-step-executor.test.ts | 48 +++++++- .../executors/condition-step-executor.test.ts | 11 +- ...rigger-record-action-step-executor.test.ts | 69 ++++++++++-- .../update-record-step-executor.test.ts | 60 ++++++++-- 13 files changed, 360 insertions(+), 205 deletions(-) diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 8651daa917..83a36eda3b 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -57,3 +57,16 @@ export class NoActionsError extends WorkflowExecutorError { super(`No actions available on collection "${collectionName}"`); } } + +/** + * Thrown when a step's side effect succeeded (action/update/decision) + * but the resulting state could not be persisted to the RunStore. + */ +export class StepPersistenceError extends WorkflowExecutorError { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + if (cause !== undefined) this.cause = cause; + } +} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index e7f72cff3a..f7a5ffa367 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -5,7 +5,7 @@ import type { LoadRelatedRecordStepExecutionData, StepExecutionData, } from '../types/step-execution-data'; -import type { StepOutcome, StepStatus } from '../types/step-outcome'; +import type { BaseStepStatus, StepOutcome } from '../types/step-outcome'; import type { AIMessage, BaseMessage } from '@langchain/core/messages'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -29,7 +29,18 @@ export default abstract class BaseStepExecutor; + async execute(): Promise { + try { + return await this.doExecute(); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return this.buildOutcomeResult({ status: 'error', error: error.message }); + } + throw error; + } + } + + protected abstract doExecute(): Promise; /** Find a field by displayName first, then fallback to fieldName. */ protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { @@ -41,22 +52,10 @@ export default abstract class BaseStepExecutor>(response: AIMessage): T { const toolCall = response.tool_calls?.[0]; - if (toolCall?.args) return toolCall.args as T; + + if (toolCall !== undefined) { + if (toolCall.args !== undefined && toolCall.args !== null) { + return toolCall.args as T; + } + + throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'args field is missing or null'); + } const invalidCall = response.invalid_tool_calls?.[0]; diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 09128a73b8..bd6288f380 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -6,6 +6,7 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; +import { StepPersistenceError } from '../errors'; import BaseStepExecutor from './base-step-executor'; interface GatewayToolArgs { @@ -52,7 +53,7 @@ export default class ConditionStepExecutor extends BaseStepExecutor { + protected async doExecute(): Promise { const { stepDefinition: step } = this.context; const tool = new DynamicStructuredTool({ @@ -78,23 +79,24 @@ export default class ConditionStepExecutor extends BaseStepExecutor(messages, tool); + const { option: selectedOption, reasoning } = args; try { - args = await this.invokeWithTool(messages, tool); - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'condition', + stepIndex: this.context.stepIndex, + executionParams: { answer: selectedOption, reasoning }, + executionResult: selectedOption ? { answer: selectedOption } : undefined, + }); + } catch (cause) { + throw new StepPersistenceError( + `Condition step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); } - const { option: selectedOption, reasoning } = args; - - await this.context.runStore.saveStepExecution(this.context.runId, { - type: 'condition', - stepIndex: this.context.stepIndex, - executionParams: { answer: selectedOption, reasoning }, - executionResult: selectedOption ? { answer: selectedOption } : undefined, - }); - if (!selectedOption) { return this.buildOutcomeResult({ status: 'manual-decision' }); } diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 26e91eb57e..5aaa6bd5c3 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,5 +1,5 @@ import type { StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, RecordRef } from '../types/record'; +import type { CollectionSchema } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult } from '../types/step-execution-data'; @@ -19,36 +19,28 @@ Important rules: - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; export default class ReadRecordStepExecutor extends RecordTaskStepExecutor { - async execute(): Promise { + protected async doExecute(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); - let selectedRecordRef: RecordRef; - let schema: CollectionSchema; - let fieldResults: FieldReadResult[]; - - try { - selectedRecordRef = await this.selectRecordRef(records, step.prompt); - schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const selectedDisplayNames = await this.selectFields(schema, step.prompt); - const resolvedFieldNames = selectedDisplayNames - .map(name => this.findField(schema, name)?.fieldName) - .filter((name): name is string => name !== undefined); - - if (resolvedFieldNames.length === 0) { - throw new NoResolvedFieldsError(selectedDisplayNames); - } - - const recordData = await this.context.agentPort.getRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - resolvedFieldNames, - ); - fieldResults = this.formatFieldResults(recordData.values, schema, selectedDisplayNames); - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const selectedDisplayNames = await this.selectFields(schema, step.prompt); + const resolvedFieldNames = selectedDisplayNames + .map(name => this.findField(schema, name)?.fieldName) + .filter((name): name is string => name !== undefined); + + if (resolvedFieldNames.length === 0) { + throw new NoResolvedFieldsError(selectedDisplayNames); } + const recordData = await this.context.agentPort.getRecord( + selectedRecordRef.collectionName, + selectedRecordRef.recordId, + resolvedFieldNames, + ); + const fieldResults = this.formatFieldResults(recordData.values, schema, selectedDisplayNames); + await this.context.runStore.saveStepExecution(this.context.runId, { type: 'read-record', stepIndex: this.context.stepIndex, diff --git a/packages/workflow-executor/src/executors/record-task-step-executor.ts b/packages/workflow-executor/src/executors/record-task-step-executor.ts index 86b8841092..15db70627e 100644 --- a/packages/workflow-executor/src/executors/record-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-task-step-executor.ts @@ -1,9 +1,15 @@ import type { StepExecutionResult } from '../types/execution'; +import type { RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; +import type { StepExecutionData } from '../types/step-execution-data'; import type { RecordTaskStepStatus } from '../types/step-outcome'; +import { WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; +/** Execution data that includes the fields required by the confirmation flow. */ +type WithPendingData = StepExecutionData & { pendingData?: object; selectedRecordRef: RecordRef }; + export default abstract class RecordTaskStepExecutor< TStep extends StepDefinition = StepDefinition, > extends BaseStepExecutor { @@ -20,4 +26,42 @@ export default abstract class RecordTaskStepExecutor< }, }; } + + /** + * Shared confirmation flow for executors that require user approval before acting. + * Handles the find → guard → skipped → delegate pattern. + */ + protected async handleConfirmationFlow( + typeDiscriminator: string, + resolveAndExecute: (execution: TExec) => Promise, + ): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = stepExecutions.find( + (e): e is TExec => + (e as TExec).type === typeDiscriminator && e.stepIndex === this.context.stepIndex, + ); + + if (!execution) { + throw new WorkflowExecutorError( + `No execution record found for step at index ${this.context.stepIndex}`, + ); + } + + if (!execution.pendingData) { + throw new WorkflowExecutorError( + `Step at index ${this.context.stepIndex} has no pending data`, + ); + } + + if (!this.context.userConfirmed) { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...execution, + executionResult: { skipped: true }, + } as StepExecutionData); + + return this.buildOutcomeResult({ status: 'success' }); + } + + return resolveAndExecute(execution); + } } 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 0fcd9f562a..bdb3a987c8 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -7,7 +7,7 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -import { NoActionsError, WorkflowExecutorError } from '../errors'; +import { NoActionsError, StepPersistenceError, WorkflowExecutorError } from '../errors'; import RecordTaskStepExecutor from './record-task-step-executor'; const TRIGGER_ACTION_SYSTEM_PROMPT = `You are an AI agent triggering an action on a record based on a user request. @@ -23,7 +23,7 @@ interface ActionTarget extends ActionRef { } export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecutor { - async execute(): Promise { + protected async doExecute(): Promise { // Branch A -- Re-entry with user confirmation if (this.context.userConfirmed !== undefined) { return this.handleConfirmation(); @@ -34,50 +34,29 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu } private async handleConfirmation(): Promise { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const execution = stepExecutions.find( - (e): e is TriggerRecordActionStepExecutionData => - e.type === 'trigger-action' && e.stepIndex === this.context.stepIndex, + return this.handleConfirmationFlow( + 'trigger-action', + async execution => { + const { selectedRecordRef, pendingData } = execution; + const target: ActionTarget = { + selectedRecordRef, + ...(pendingData as ActionRef), + }; + + return this.resolveAndExecute(target, execution); + }, ); - - if (!execution?.pendingData) { - throw new WorkflowExecutorError('No pending action found for this step'); - } - - if (!this.context.userConfirmed) { - await this.context.runStore.saveStepExecution(this.context.runId, { - ...execution, - executionResult: { skipped: true }, - }); - - return this.buildOutcomeResult({ status: 'success' }); - } - - const { selectedRecordRef, pendingData } = execution; - const target: ActionTarget = { - selectedRecordRef, - displayName: pendingData.displayName, - name: pendingData.name, - }; - - return this.resolveAndExecute(target, execution); } private async handleFirstCall(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); - let target: ActionTarget; - - try { - const selectedRecordRef = await this.selectRecordRef(records, step.prompt); - const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const args = await this.selectAction(schema, step.prompt); - const name = this.resolveActionName(schema, args.actionName); - target = { selectedRecordRef, displayName: args.actionName, name }; - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); - } + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectAction(schema, step.prompt); + const name = this.resolveActionName(schema, args.actionName); + const target: ActionTarget = { selectedRecordRef, displayName: args.actionName, name }; // Branch B -- automaticExecution if (step.automaticExecution) { @@ -98,7 +77,7 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu /** * Resolves the action name, calls executeAction, and persists execution data. * When `existingExecution` is provided (confirmation flow), it is spread into the - * saved execution to preserve pendingAction for traceability. + * saved execution to preserve pendingData for traceability. */ private async resolveAndExecute( target: ActionTarget, @@ -106,25 +85,29 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu ): Promise { const { selectedRecordRef, displayName, name } = target; + // Return value intentionally discarded: action results may contain client data + // and must not leave the client's infrastructure (privacy constraint). + await this.context.agentPort.executeAction(selectedRecordRef.collectionName, name, [ + selectedRecordRef.recordId, + ]); + try { - // Return value intentionally discarded: action results may contain client data - // and must not leave the client's infrastructure (privacy constraint). - await this.context.agentPort.executeAction(selectedRecordRef.collectionName, name, [ - selectedRecordRef.recordId, - ]); - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { success: true }, + selectedRecordRef, + }); + } catch (cause) { + throw new StepPersistenceError( + `Action "${name}" executed but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); } - await this.context.runStore.saveStepExecution(this.context.runId, { - ...existingExecution, - type: 'trigger-action', - stepIndex: this.context.stepIndex, - executionParams: { displayName, name }, - executionResult: { success: true }, - selectedRecordRef, - }); - return this.buildOutcomeResult({ status: 'success' }); } 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 a5555c74ae..edc2e3cbc4 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -7,7 +7,7 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -import { NoWritableFieldsError, WorkflowExecutorError } from '../errors'; +import { NoWritableFieldsError, StepPersistenceError, WorkflowExecutorError } from '../errors'; import RecordTaskStepExecutor from './record-task-step-executor'; const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. @@ -24,7 +24,7 @@ interface UpdateTarget extends FieldRef { } export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { - async execute(): Promise { + protected async doExecute(): Promise { // Branch A -- Re-entry with user confirmation if (this.context.userConfirmed !== undefined) { return this.handleConfirmation(); @@ -35,56 +35,34 @@ export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const execution = stepExecutions.find( - (e): e is UpdateRecordStepExecutionData => - e.type === 'update-record' && e.stepIndex === this.context.stepIndex, + return this.handleConfirmationFlow( + 'update-record', + async execution => { + const { selectedRecordRef, pendingData } = execution; + const target: UpdateTarget = { + selectedRecordRef, + ...(pendingData as FieldRef & { value: string }), + }; + + return this.resolveAndUpdate(target, execution); + }, ); - - if (!execution?.pendingData) { - throw new WorkflowExecutorError('No pending update found for this step'); - } - - if (!this.context.userConfirmed) { - await this.context.runStore.saveStepExecution(this.context.runId, { - ...execution, - executionResult: { skipped: true }, - }); - - return this.buildOutcomeResult({ status: 'success' }); - } - - const { selectedRecordRef, pendingData } = execution; - const target: UpdateTarget = { - selectedRecordRef, - displayName: pendingData.displayName, - name: pendingData.name, - value: pendingData.value, - }; - - return this.resolveAndUpdate(target, execution); } private async handleFirstCall(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); - let target: UpdateTarget; - - try { - const selectedRecordRef = await this.selectRecordRef(records, step.prompt); - const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const args = await this.selectFieldAndValue(schema, step.prompt); - const name = this.resolveFieldName(schema, args.fieldName); - target = { - selectedRecordRef, - displayName: args.fieldName, - name, - value: args.value, - }; - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); - } + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectFieldAndValue(schema, step.prompt); + const name = this.resolveFieldName(schema, args.fieldName); + const target: UpdateTarget = { + selectedRecordRef, + displayName: args.fieldName, + name, + value: args.value, + }; // Branch B -- automaticExecution if (step.automaticExecution) { @@ -109,34 +87,37 @@ export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { const { selectedRecordRef, displayName, name, value } = target; - let updated: { values: Record }; + + const updated = await this.context.agentPort.updateRecord( + selectedRecordRef.collectionName, + selectedRecordRef.recordId, + { [name]: value }, + ); try { - updated = await this.context.agentPort.updateRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - { [name]: value }, + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'update-record', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name, value }, + executionResult: { updatedValues: updated.values }, + selectedRecordRef, + }); + } catch (cause) { + throw new StepPersistenceError( + `Record update persisted but step state could not be saved ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, ); - } catch (error) { - return this.buildErrorOutcomeOrThrow(error); } - await this.context.runStore.saveStepExecution(this.context.runId, { - ...existingExecution, - type: 'update-record', - stepIndex: this.context.stepIndex, - executionParams: { displayName, name, value }, - executionResult: { updatedValues: updated.values }, - selectedRecordRef, - }); - return this.buildOutcomeResult({ status: 'success' }); } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index b4205fb673..b32f9358e6 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -6,8 +6,6 @@ import type { RecordRef } from './record'; interface BaseStepExecutionData { stepIndex: number; - /** Populated by executors awaiting confirmation; used to display pending state in AI context. */ - pendingData?: object; } // -- Condition -- @@ -15,7 +13,7 @@ interface BaseStepExecutionData { export interface ConditionStepExecutionData extends BaseStepExecutionData { type: 'condition'; executionParams: { answer: string | null; reasoning?: string }; - executionResult: { answer: string }; + executionResult?: { answer: string }; } // -- Shared -- diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index 37f53afa00..b5df5ac9ec 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -type BaseStepStatus = 'success' | 'error'; +export type BaseStepStatus = 'success' | 'error'; /** Condition steps can fall back to human decision when the AI is uncertain. */ export type ConditionStepStatus = BaseStepStatus | 'manual-decision'; 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 76ba1c64cb..ee1af98642 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -4,22 +4,41 @@ import type { ExecutionContext, StepExecutionResult } from '../../src/types/exec import type { RecordRef } from '../../src/types/record'; import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; -import type { StepOutcome } from '../../src/types/step-outcome'; +import type { BaseStepStatus, StepOutcome } from '../../src/types/step-outcome'; import type { BaseMessage, SystemMessage } from '@langchain/core/messages'; import type { DynamicStructuredTool } from '@langchain/core/tools'; -import { MalformedToolCallError, MissingToolCallError } from '../../src/errors'; +import { MalformedToolCallError, MissingToolCallError, NoRecordsError } from '../../src/errors'; import BaseStepExecutor from '../../src/executors/base-step-executor'; import { StepType } from '../../src/types/step-definition'; /** Concrete subclass that exposes protected methods for testing. */ class TestableExecutor extends BaseStepExecutor { - async execute(): Promise { - throw new Error('not used'); + constructor( + context: ExecutionContext, + private readonly errorToThrow?: unknown, + ) { + super(context); } - protected buildOutcomeResult(): StepExecutionResult { - throw new Error('not used'); + protected async doExecute(): Promise { + if (this.errorToThrow !== undefined) throw this.errorToThrow; + return this.buildOutcomeResult({ status: 'success' }); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; } override buildPreviousStepsMessages(): Promise { @@ -456,6 +475,23 @@ describe('BaseStepExecutor', () => { }); }); + describe('execute error handling', () => { + it('converts NoRecordsError to error outcome', async () => { + const executor = new TestableExecutor(makeContext(), new NoRecordsError()); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No records available'); + }); + + it('rethrows non-WorkflowExecutorError errors', async () => { + const executor = new TestableExecutor(makeContext(), new Error('infrastructure failure')); + + await expect(executor.execute()).rejects.toThrow('infrastructure failure'); + }); + }); + describe('invokeWithTool', () => { function makeMockModel(response: unknown) { const invoke = jest.fn().mockResolvedValue(response); 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 c439042448..7a4c5fa6b9 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -252,6 +252,7 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('condition'); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( 'AI returned a malformed tool call for "choose-gateway-option": JSON parse error', @@ -264,15 +265,18 @@ describe('ConditionStepExecutor', () => { it('lets infrastructure errors propagate', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); const context = makeContext({ model: { bindTools } as unknown as ExecutionContext['model'], + runStore, }); const executor = new ConditionStepExecutor(context); await expect(executor.execute()).rejects.toThrow('API timeout'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('lets run store errors propagate', async () => { + it('returns error outcome when run store save fails with context', async () => { const mockModel = makeMockModel({ option: 'Approve', reasoning: 'OK', @@ -283,7 +287,10 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model, runStore })); - await expect(executor.execute()).rejects.toThrow('Storage full'); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain('Condition step state could not be persisted'); }); }); }); 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 d5bd9eb871..0cb3620a7f 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 @@ -268,7 +268,7 @@ describe('TriggerRecordActionStepExecutor', () => { }); describe('no pending action in confirmation flow (Branch A)', () => { - it('throws when no pending action is found', async () => { + it('returns error outcome when no pending action is found', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]), }); @@ -276,10 +276,19 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'No execution record found for step at index 0', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('throws when execution exists but stepIndex does not match', async () => { + it('returns error outcome when execution exists but stepIndex does not match', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -294,10 +303,19 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'No execution record found for step at index 0', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('throws when execution exists but pendingAction is absent', async () => { + it('returns error outcome when execution exists but pendingData is absent', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -311,7 +329,16 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending action found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'Step at index 0 has no pending data', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -388,6 +415,9 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Action not permitted'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -418,6 +448,9 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Action not permitted'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -639,6 +672,9 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( 'AI returned a malformed tool call for "select-action": JSON parse error', @@ -658,6 +694,9 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('AI did not return a tool call'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -707,7 +746,7 @@ describe('TriggerRecordActionStepExecutor', () => { await expect(executor.execute()).rejects.toThrow('Disk full'); }); - it('lets saveStepExecution errors propagate after successful executeAction (Branch B)', async () => { + it('returns error outcome after successful executeAction when saveStepExecution fails (Branch B)', async () => { const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); @@ -717,10 +756,15 @@ describe('TriggerRecordActionStepExecutor', () => { }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain( + 'Action "send-welcome-email" executed but step state could not be persisted', + ); }); - it('lets saveStepExecution errors propagate after successful executeAction (Branch A confirmed)', async () => { + it('returns error outcome after successful executeAction when saveStepExecution fails (Branch A confirmed)', async () => { const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, @@ -738,7 +782,12 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain( + 'Action "send-welcome-email" executed but step state could not be persisted', + ); }); }); 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 8b4ebda1ca..286fea68f5 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 @@ -253,7 +253,7 @@ describe('UpdateRecordStepExecutor', () => { }); describe('no pending update in phase 2 (Branch A)', () => { - it('throws when no pending update is found', async () => { + it('returns error outcome when no pending update is found', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]), }); @@ -261,10 +261,19 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'No execution record found for step at index 0', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('throws when execution exists but stepIndex does not match', async () => { + it('returns error outcome when execution exists but stepIndex does not match', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -279,10 +288,19 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'No execution record found for step at index 0', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('throws when execution exists but pendingUpdate is absent', async () => { + it('returns error outcome when execution exists but pendingData is absent', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -296,7 +314,16 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'Step at index 0 has no pending data', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -481,6 +508,9 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( 'AI returned a malformed tool call for "update-record-field": JSON parse error', @@ -500,6 +530,9 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('AI did not return a tool call'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -528,6 +561,9 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Record locked'); }); @@ -554,6 +590,9 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('Record locked'); }); @@ -689,7 +728,7 @@ describe('UpdateRecordStepExecutor', () => { await expect(executor.execute()).rejects.toThrow('Disk full'); }); - it('lets saveStepExecution errors propagate after successful updateRecord (Branch B)', async () => { + it('returns error outcome after successful updateRecord when saveStepExecution fails (Branch B)', async () => { const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); @@ -699,7 +738,12 @@ describe('UpdateRecordStepExecutor', () => { }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain( + 'Record update persisted but step state could not be saved', + ); }); }); From 39d3e5de153eca0bce6f7fc2140163091cfc73e5 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 11:47:10 +0100 Subject: [PATCH 10/34] refactor(workflow-executor): add load-related-record executor, typed error hierarchy, and AI-first Branch C - Add LoadRelatedRecordStepExecutor with Branch A/B/C confirmation flow - Replace candidates[] in LoadRelatedRecordPendingData with AI-selected suggestedRecordId/suggestedFields/relatedCollectionName - Extract selectBestFromRelatedData to share AI selection logic between Branch B and Branch C - Make WorkflowExecutorError abstract; introduce InvalidAIResponseError, RelationNotFoundError, FieldNotFoundError, ActionNotFoundError, StepStateError - Fix selectRelevantFields to use displayName in Zod enum and map back to fieldName - Add getRelatedData fields param forwarding in AgentClientAgentPort - Update CLAUDE.md with displayName-in-AI-tools and error hierarchy rules Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/CLAUDE.md | 7 +- .../src/adapters/agent-client-agent-port.ts | 85 +- packages/workflow-executor/src/errors.ts | 47 +- .../src/executors/base-step-executor.ts | 12 +- .../load-related-record-step-executor.ts | 416 +++++ .../executors/read-record-step-executor.ts | 10 +- .../executors/record-task-step-executor.ts | 8 +- .../trigger-record-action-step-executor.ts | 14 +- .../executors/update-record-step-executor.ts | 16 +- packages/workflow-executor/src/index.ts | 15 +- .../workflow-executor/src/ports/agent-port.ts | 37 +- .../workflow-executor/src/types/record.ts | 2 + .../src/types/step-execution-data.ts | 50 +- .../adapters/agent-client-agent-port.test.ts | 122 +- .../test/executors/base-step-executor.test.ts | 6 +- .../load-related-record-step-executor.test.ts | 1472 +++++++++++++++++ .../read-record-step-executor.test.ts | 68 +- ...rigger-record-action-step-executor.test.ts | 47 +- .../update-record-step-executor.test.ts | 41 +- 19 files changed, 2282 insertions(+), 193 deletions(-) create mode 100644 packages/workflow-executor/src/executors/load-related-record-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 2a006024ba..1bb8c3326f 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -42,7 +42,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor ── ``` src/ -├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError, NoActionsError +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError, NoActionsError, StepPersistenceError, NoRelationshipFieldsError, RelatedRecordNotFoundError ├── runner.ts # Runner class — main entry point (start/stop/triggerPoll, HTTP server wiring) ├── types/ # Core type definitions (@draft) │ ├── step-definition.ts # StepType enum + step definition interfaces @@ -62,7 +62,8 @@ src/ │ ├── condition-step-executor.ts # AI-powered condition step (chooses among options) │ ├── read-record-step-executor.ts # AI-powered record field reading step │ ├── update-record-step-executor.ts # AI-powered record field update step (with confirmation flow) -│ └── trigger-action-step-executor.ts # AI-powered action trigger step (with confirmation flow) +│ ├── trigger-record-action-step-executor.ts # AI-powered action trigger step (with confirmation flow) +│ └── load-related-record-step-executor.ts # AI-powered relation loading step (with confirmation flow) ├── http/ # HTTP server (optional, for frontend data access) │ └── executor-http-server.ts # Koa server: GET /runs/:runId, POST /runs/:runId/trigger └── index.ts # Barrel exports @@ -75,6 +76,8 @@ src/ - **Privacy** — Zero client data leaves the client's infrastructure. `StepOutcome` is sent to the orchestrator and must NEVER contain client data. Privacy-sensitive information (e.g. AI reasoning) must stay in `StepExecutionData` (persisted in the RunStore, client-side only). - **Ports (IO injection)** — All external IO goes through injected port interfaces, keeping the core pure and testable. - **AI integration** — Uses `@langchain/core` (`BaseChatModel`, `DynamicStructuredTool`) for AI-powered steps. `ExecutionContext.model` is a `BaseChatModel`. +- **Error hierarchy** — All domain errors must extend `WorkflowExecutorError` (defined in `src/errors.ts`). This ensures executors can distinguish domain errors (caught → error outcome) from infrastructure errors (uncaught → propagate to caller). Never throw a plain `Error` for a domain error case. +- **displayName in AI tools** — All `DynamicStructuredTool` schemas and system message prompts must use `displayName`, never `fieldName`. `displayName` is a Forest Admin frontend feature that replaces the technical field/relation/action name with a product-oriented label configured by the Forest Admin admin. End users write their workflow prompts using these display names, not the underlying technical names. After an AI tool call returns display names, map them back to `fieldName`/`name` before using them in datasource operations (e.g. filtering record values, calling `getRecord`). - **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. ## Commands diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index cf8949a1a6..5dbfd14877 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,4 +1,4 @@ -import type { AgentPort } from '../ports/agent-port'; +import type { AgentPort, Id, Limit, QueryBase } from '../ports/agent-port'; import type { CollectionSchema } from '../types/record'; import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; @@ -6,10 +6,10 @@ import { RecordNotFoundError } from '../errors'; function buildPkFilter( primaryKeyFields: string[], - recordId: Array, + ids: Array, ): SelectOptions['filters'] { if (primaryKeyFields.length === 1) { - return { field: primaryKeyFields[0], operator: 'Equal', value: recordId[0] }; + return { field: primaryKeyFields[0], operator: 'Equal', value: ids[0] }; } return { @@ -17,14 +17,14 @@ function buildPkFilter( conditions: primaryKeyFields.map((field, i) => ({ field, operator: 'Equal', - value: recordId[i], + value: ids[i], })), }; } // agent-client methods (update, relation, action) still expect the pipe-encoded string format -function encodePk(recordId: Array): string { - return recordId.map(v => String(v)).join('|'); +function encodePk(ids: Array): string { + return ids.map(v => String(v)).join('|'); } function extractRecordId( @@ -46,44 +46,45 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionSchemas = params.collectionSchemas; } - async getRecord(collectionName: string, recordId: Array, fieldNames?: string[]) { - const schema = this.resolveSchema(collectionName); - const records = await this.client.collection(collectionName).list>({ - filters: buildPkFilter(schema.primaryKeyFields, recordId), + async getRecord({ collection, ids, fields }: QueryBase) { + const schema = this.resolveSchema(collection); + const records = await this.client.collection(collection).list>({ + filters: buildPkFilter(schema.primaryKeyFields, ids), pagination: { size: 1, number: 1 }, - ...(fieldNames?.length && { fields: fieldNames }), + ...(fields?.length && { fields }), }); if (records.length === 0) { - throw new RecordNotFoundError(collectionName, encodePk(recordId)); + throw new RecordNotFoundError(collection, encodePk(ids)); } - return { collectionName, recordId, values: records[0] }; + return { collectionName: collection, recordId: ids, values: records[0] }; } - async updateRecord( - collectionName: string, - recordId: Array, - values: Record, - ) { + async updateRecord({ collection, ids, values }: QueryBase & { values: Record }) { const updatedRecord = await this.client - .collection(collectionName) - .update>(encodePk(recordId), values); + .collection(collection) + .update>(encodePk(ids), values); - return { collectionName, recordId, values: updatedRecord }; + return { collectionName: collection, recordId: ids, values: updatedRecord }; } - async getRelatedData( - collectionName: string, - recordId: Array, - relationName: string, - ) { - const relatedSchema = this.resolveSchema(relationName); + async getRelatedData({ + collection, + ids, + relation, + limit, + fields, + }: QueryBase & { relation: string } & Limit) { + const relatedSchema = this.resolveSchema(relation); const records = await this.client - .collection(collectionName) - .relation(relationName, encodePk(recordId)) - .list>(); + .collection(collection) + .relation(relation, encodePk(ids)) + .list>({ + ...(limit !== null && { pagination: { size: limit, number: 1 } }), + ...(fields?.length && { fields }), + }); return records.map(record => ({ collectionName: relatedSchema.collectionName, @@ -92,17 +93,19 @@ export default class AgentClientAgentPort implements AgentPort { })); } - async executeAction( - collectionName: string, - actionName: string, - recordIds: Array[], - ): Promise { - const encodedIds = recordIds.map(id => encodePk(id)); - const action = await this.client - .collection(collectionName) - .action(actionName, { recordIds: encodedIds }); - - return action.execute(); + async executeAction({ + collection, + action, + ids, + }: { + collection: string; + action: string; + ids?: Id[]; + }): Promise { + const encodedIds = ids?.length ? [encodePk(ids)] : []; + const act = await this.client.collection(collection).action(action, { recordIds: encodedIds }); + + return act.execute(); } private resolveSchema(collectionName: string): CollectionSchema { diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 83a36eda3b..422c2413de 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -1,6 +1,6 @@ /* eslint-disable max-classes-per-file */ -export class WorkflowExecutorError extends Error { +export abstract class WorkflowExecutorError extends Error { constructor(message: string) { super(message); this.name = this.constructor.name; @@ -63,10 +63,53 @@ export class NoActionsError extends WorkflowExecutorError { * but the resulting state could not be persisted to the RunStore. */ export class StepPersistenceError extends WorkflowExecutorError { - readonly cause?: unknown; + // Not readonly — allows standard Error.cause semantics without shadowing the built-in with a + // stricter modifier that would prevent downstream code from re-assigning if needed. + cause?: unknown; constructor(message: string, cause?: unknown) { super(message); if (cause !== undefined) this.cause = cause; } } + +export class NoRelationshipFieldsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super(`No relationship fields on record from collection "${collectionName}"`); + } +} + +export class RelatedRecordNotFoundError extends WorkflowExecutorError { + constructor(collectionName: string, relationName: string) { + super( + `No related record found for relation "${relationName}" on collection "${collectionName}"`, + ); + } +} + +/** Thrown when the AI returns a response that violates expected constraints (bad index, empty selection, unknown identifier, etc.). */ +export class InvalidAIResponseError extends WorkflowExecutorError {} + +/** Thrown when a named relation is not found in the collection schema. */ +export class RelationNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super(`Relation "${name}" not found in collection "${collectionName}"`); + } +} + +/** Thrown when a named field is not found in the collection schema. */ +export class FieldNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super(`Field "${name}" not found in collection "${collectionName}"`); + } +} + +/** Thrown when a named action is not found in the collection schema. */ +export class ActionNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super(`Action "${name}" not found in collection "${collectionName}"`); + } +} + +/** Thrown when step execution state is invalid (missing execution record, missing pending data, etc.). */ +export class StepStateError extends WorkflowExecutorError {} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index f7a5ffa367..2a57c78896 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -13,12 +13,12 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; import { + InvalidAIResponseError, MalformedToolCallError, MissingToolCallError, NoRecordsError, WorkflowExecutorError, } from '../errors'; -import { isExecutedStepOnExecutor } from '../types/step-execution-data'; export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; @@ -36,6 +36,7 @@ export default abstract class BaseStepExecutor { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); const relatedRecords = stepExecutions - .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') + .filter( + (e): e is LoadRelatedRecordStepExecutionData & { record: RecordRef } => + e.type === 'load-related-record' && e.record !== undefined, + ) .map(e => e.record); return [this.context.baseRecordRef, ...relatedRecords]; @@ -202,7 +206,7 @@ export default abstract class BaseStepExecutor { + protected async doExecute(): Promise { + // Branch A -- Re-entry with user confirmation + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); + } + + // Branches B & C -- First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + return this.handleConfirmationFlow( + 'load-related-record', + async execution => this.resolveFromSelection(execution), + ); + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectRelation(schema, step.prompt); + const target = this.buildTarget(schema, args.relationName, selectedRecordRef); + + // Branch B -- automaticExecution + if (step.automaticExecution) { + return this.resolveAndLoadAutomatic(target); + } + + // Branch C -- pre-fetch candidates, await user confirmation + return this.saveAndAwaitInput(target); + } + + private buildTarget( + schema: CollectionSchema, + relationName: string, + selectedRecordRef: RecordRef, + ): RelationTarget { + const field = this.findField(schema, relationName); + + if (!field) { + throw new RelationNotFoundError(relationName, schema.collectionName); + } + + return { + selectedRecordRef, + displayName: field.displayName, + name: field.fieldName, + relationType: field.relationType, + }; + } + + /** + * Branch C: uses AI to select the best candidate, persists pendingData with suggestion, returns awaiting-input. + * Unlike persistAndReturn (Branches A/B), storage errors propagate directly here: + * the relation-load has not yet happened so the step can safely be retried. + */ + private async saveAndAwaitInput(target: RelationTarget): Promise { + const { selectedRecordRef, name, displayName } = target; + + const { relatedData, bestIndex, suggestedFields } = await this.selectBestFromRelatedData( + target, + 50, + ); + + const relatedCollectionName = relatedData[0].collectionName; + const suggestedRecordId = relatedData[bestIndex].recordId; + + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'load-related-record', + stepIndex: this.context.stepIndex, + pendingData: { displayName, name, relatedCollectionName, suggestedFields, suggestedRecordId }, + selectedRecordRef, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + /** Branch B: automatic execution. HasMany uses 2 AI calls; others take the first result. */ + private async resolveAndLoadAutomatic(target: RelationTarget): Promise { + const record = + target.relationType === 'HasMany' + ? await this.selectBestRelatedRecord(target) + : await this.fetchFirstCandidate(target); + + return this.persistAndReturn(record, target, undefined); + } + + /** + * Branch A: builds RecordRef from pendingData suggestion or user's selectedRecordId override. + * No additional getRelatedData call. + */ + private async resolveFromSelection( + execution: LoadRelatedRecordStepExecutionData, + ): Promise { + const { selectedRecordRef, pendingData } = execution; + + if (!pendingData) { + throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); + } + + const { name, displayName, relatedCollectionName, suggestedRecordId, selectedRecordId } = + pendingData; + + const record: RecordRef = { + collectionName: relatedCollectionName, + recordId: selectedRecordId ?? suggestedRecordId, + stepIndex: this.context.stepIndex, + }; + + return this.persistAndReturn(record, { selectedRecordRef, name, displayName }, execution); + } + + /** + * Fetches up to `limit` related records and uses AI to select the best one when multiple exist. + * Returns the full RecordData array, the best index, and the AI-selected fields. + */ + private async selectBestFromRelatedData( + target: Pick, + limit: number, + ): Promise<{ relatedData: RecordData[]; bestIndex: number; suggestedFields: string[] }> { + const { selectedRecordRef, name } = target; + + const relatedData = await this.context.agentPort.getRelatedData({ + collection: selectedRecordRef.collectionName, + ids: selectedRecordRef.recordId, + relation: name, + limit, + }); + + if (relatedData.length === 0) { + throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); + } + + if (relatedData.length === 1) { + return { relatedData, bestIndex: 0, suggestedFields: [] }; + } + + const relatedSchema = await this.getCollectionSchema(relatedData[0].collectionName); + const suggestedFields = await this.selectRelevantFields( + relatedSchema, + this.context.stepDefinition.prompt, + ); + const bestIndex = await this.selectBestRecordIndex( + relatedData, + suggestedFields, + this.context.stepDefinition.prompt, + ); + + return { relatedData, bestIndex, suggestedFields }; + } + + /** HasMany + automaticExecution: fetch top 50, then AI calls to select the best record. */ + private async selectBestRelatedRecord(target: RelationTarget): Promise { + const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50); + + return this.toRecordRef(relatedData[bestIndex]); + } + + /** BelongsTo / HasOne: fetch 1 record and take it directly. */ + private async fetchFirstCandidate(target: RelationTarget): Promise { + const candidates = await this.fetchCandidates(target, 1); + + return candidates[0]; + } + + /** + * Fetches related records and converts them to RecordRefs. + * Throws RelatedRecordNotFoundError when the result is empty. + */ + private async fetchCandidates( + target: Pick, + limit: number, + ): Promise { + const { selectedRecordRef, name } = target; + const relatedData = await this.context.agentPort.getRelatedData({ + collection: selectedRecordRef.collectionName, + ids: selectedRecordRef.recordId, + relation: name, + limit, + }); + + if (relatedData.length === 0) { + throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); + } + + return relatedData.map(r => this.toRecordRef(r)); + } + + /** Persists the loaded record ref and returns a success outcome. */ + private async persistAndReturn( + record: RecordRef, + target: Pick, + existingExecution: LoadRelatedRecordStepExecutionData | undefined, + ): Promise { + const { selectedRecordRef, name, displayName } = target; + + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'load-related-record', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { record }, + selectedRecordRef, + record, + }); + } catch (cause) { + throw new StepPersistenceError( + `Related record loaded but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async selectRelation( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ relationName: string; reasoning: string }> { + const tool = this.buildSelectRelationTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(SELECT_RELATION_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Load the relevant related record.'}`), + ]; + + return this.invokeWithTool<{ relationName: string; reasoning: string }>(messages, tool); + } + + private buildSelectRelationTool(schema: CollectionSchema): DynamicStructuredTool { + const relationFields = schema.fields.filter(f => f.isRelationship); + + if (relationFields.length === 0) { + throw new NoRelationshipFieldsError(schema.collectionName); + } + + const displayNames = relationFields.map(f => f.displayName) as [string, ...string[]]; + const technicalNames = relationFields + .map(f => `${f.displayName} (technical name: ${f.fieldName})`) + .join(', '); + + return new DynamicStructuredTool({ + name: 'select-relation', + description: 'Select the relation to follow from the record.', + schema: z.object({ + relationName: z + .enum(displayNames) + .describe(`The name of the relation to follow. Available: ${technicalNames}`), + reasoning: z.string().describe('Why this relation was chosen'), + }), + func: undefined, + }); + } + + /** AI call 1 for HasMany: selects the most relevant fields to compare candidates. */ + private async selectRelevantFields( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise { + const nonRelationFields = schema.fields.filter(f => !f.isRelationship); + + if (nonRelationFields.length === 0) return []; + + // Use displayName in both the enum and the prompt for consistency — the AI sees human-readable + // names throughout. Results are mapped back to technical fieldNames before returning. + const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; + + const tool = new DynamicStructuredTool({ + name: 'select-fields', + description: 'Select the most relevant fields to identify the right record.', + schema: z.object({ + fieldNames: z + .array(z.enum(displayNames)) + .min(1) + .describe('Field names most useful for identifying the relevant record'), + }), + func: undefined, + }); + + const messages = [ + new SystemMessage(SELECT_FIELDS_SYSTEM_PROMPT), + new SystemMessage( + `The related records are from the "${schema.collectionDisplayName}" collection. ` + + `Available fields: ${nonRelationFields.map(f => f.displayName).join(', ')}.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Select the most relevant record.'}`), + ]; + + const { fieldNames: selectedDisplayNames } = await this.invokeWithTool<{ + fieldNames: string[]; + }>(messages, tool); + + // Zod's .min(1) shapes the prompt but is NOT validated against the AI response. + // Guard explicitly to avoid silently passing all fields to selectBestRecordIndex. + if (selectedDisplayNames.length === 0) { + throw new InvalidAIResponseError( + `AI returned no field names for field selection in collection "${schema.collectionName}"`, + ); + } + + // Map display names back to technical field names — values in RecordData are keyed by fieldName. + return selectedDisplayNames.map( + dn => nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn, + ); + } + + /** AI call 2 for HasMany: selects the best record by index from the candidate list. */ + private async selectBestRecordIndex( + candidates: RecordData[], + fieldNames: string[], + prompt: string | undefined, + ): Promise { + const maxIndex = candidates.length - 1; + const filteredCandidates = candidates.map((c, i) => ({ + index: i, + values: + fieldNames.length > 0 + ? Object.fromEntries(Object.entries(c.values).filter(([k]) => fieldNames.includes(k))) + : c.values, + })); + + const tool = new DynamicStructuredTool({ + name: 'select-record-by-content', + description: 'Select the most relevant related record by its index.', + schema: z.object({ + recordIndex: z + .number() + .int() + .min(0) + .max(maxIndex) + .describe(`0-based index of the most relevant record (0 to ${maxIndex})`), + reasoning: z.string().describe('Why this record was chosen'), + }), + func: undefined, + }); + + const recordList = filteredCandidates + .map(c => `[${c.index}] ${JSON.stringify(c.values)}`) + .join('\n'); + + const messages = [ + new SystemMessage(SELECT_RECORD_SYSTEM_PROMPT), + new SystemMessage(`Candidates:\n${recordList}`), + new HumanMessage(`**Request**: ${prompt ?? 'Select the most relevant record.'}`), + ]; + + const { recordIndex } = await this.invokeWithTool<{ recordIndex: number; reasoning: string }>( + messages, + tool, + ); + + // NOTE: The Zod schema's .min(0).max(maxIndex) shapes the tool prompt only — it is NOT + // validated against the AI response. This guard is the sole runtime enforcement. + if (recordIndex < 0 || recordIndex > maxIndex) { + throw new InvalidAIResponseError( + `AI selected record index ${recordIndex} which is out of range (0-${maxIndex})`, + ); + } + + return recordIndex; + } + + private toRecordRef(data: RecordData): RecordRef { + return { + collectionName: data.collectionName, + recordId: data.recordId, + stepIndex: this.context.stepIndex, + }; + } +} diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 5aaa6bd5c3..36ab70756f 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -34,11 +34,11 @@ export default class ReadRecordStepExecutor extends RecordTaskStepExecutor a.name === displayName); if (!action) { - throw new WorkflowExecutorError( - `Action "${displayName}" not found in collection "${schema.collectionName}"`, - ); + throw new ActionNotFoundError(displayName, schema.collectionName); } return action.name; 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 edc2e3cbc4..fe0e43e8cf 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -7,7 +7,7 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -import { NoWritableFieldsError, StepPersistenceError, WorkflowExecutorError } from '../errors'; +import { FieldNotFoundError, NoWritableFieldsError, StepPersistenceError } from '../errors'; import RecordTaskStepExecutor from './record-task-step-executor'; const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. @@ -95,11 +95,11 @@ export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { const { selectedRecordRef, displayName, name, value } = target; - const updated = await this.context.agentPort.updateRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - { [name]: value }, - ); + const updated = await this.context.agentPort.updateRecord({ + collection: selectedRecordRef.collectionName, + ids: selectedRecordRef.recordId, + values: { [name]: value }, + }); try { await this.context.runStore.saveStepExecution(this.context.runId, { @@ -168,9 +168,7 @@ export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor, - fieldNames?: string[], - ): Promise; - updateRecord( - collectionName: string, - recordId: Array, - values: Record, - ): Promise; - getRelatedData( - collectionName: string, - recordId: Array, - relationName: string, - ): Promise; - executeAction( - collectionName: string, - actionName: string, - recordIds: Array[], - ): Promise; + getRecord(query: QueryBase): Promise; + + updateRecord(query: QueryBase & { values: Record }): Promise; + + getRelatedData(query: QueryBase & { relation: string } & Limit): Promise; + + executeAction(query: { collection: string; action: string; ids?: Id[] }): Promise; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index b5070c39f4..2237600fb7 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -6,6 +6,8 @@ export interface FieldSchema { fieldName: string; displayName: string; isRelationship: boolean; + /** Cardinality of the relation. Absent for non-relationship fields. */ + relationType?: 'BelongsTo' | 'HasMany' | 'HasOne'; } export interface ActionSchema { diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index b32f9358e6..3fa13c8935 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -61,6 +61,13 @@ export interface ActionRef { displayName: string; } +// Intentionally separate from ActionRef/FieldRef: expected to gain relation-specific +// fields (e.g. relationType) in a future iteration. +export interface RelationRef { + name: string; + displayName: string; +} + export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionData { type: 'trigger-action'; /** Display name and technical name of the executed action. */ @@ -82,9 +89,34 @@ export interface RecordTaskStepExecutionData extends BaseStepExecutionData { // -- Load Related Record -- +export interface LoadRelatedRecordPendingData extends RelationRef { + /** Collection name of the related records — needed to build RecordRef in Branch A. */ + relatedCollectionName: string; + /** AI-selected fields suggested for display on the frontend. undefined = not computed (no non-relation fields). */ + suggestedFields?: string[]; + /** AI's best pick from the 50 candidates — proposed to the user as default. */ + suggestedRecordId: Array; + /** + * Record id chosen by the user. Written by the HTTP endpoint (dedicated ticket, not yet implemented). + * Falls back to suggestedRecordId when absent. + */ + selectedRecordId?: Array; +} + export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { type: 'load-related-record'; - record: RecordRef; + /** + * The record ref of the loaded related record. Absent during the pending phase. + * Also stored in executionResult.record for display consistency with other step types. + * This top-level field is used by getAvailableRecordRefs to build the record pool. + */ + record?: RecordRef; + /** AI-selected relation with pre-fetched candidates awaiting user confirmation. */ + pendingData?: LoadRelatedRecordPendingData; + /** The record ref used to load the relation. Required for handleConfirmationFlow. */ + selectedRecordRef: RecordRef; + executionParams?: RelationRef; + executionResult?: { record: RecordRef } | { skipped: true }; } // -- Union -- @@ -97,17 +129,5 @@ export type StepExecutionData = | RecordTaskStepExecutionData | LoadRelatedRecordStepExecutionData; -export type ExecutedStepExecutionData = - | ConditionStepExecutionData - | ReadRecordStepExecutionData - | UpdateRecordStepExecutionData - | TriggerRecordActionStepExecutionData - | RecordTaskStepExecutionData; - -// TODO: this condition should change when load-related-record gets its own executor -// and produces executionParams/executionResult like other steps. -export function isExecutedStepOnExecutor( - data: StepExecutionData | undefined, -): data is ExecutedStepExecutionData { - return !!data && data.type !== 'load-related-record'; -} +/** Alias for StepExecutionData — kept for backwards-compatible consumption at the call sites. */ +export type ExecutedStepExecutionData = StepExecutionData; diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index b564eeaf5e..38b8f4dd6e 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -77,7 +77,7 @@ describe('AgentClientAgentPort', () => { it('should return a RecordData for a simple PK', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - const result = await port.getRecord('users', [42]); + const result = await port.getRecord({ collection: 'users', ids: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -93,7 +93,7 @@ describe('AgentClientAgentPort', () => { it('should build a composite And filter for composite PKs', async () => { mockCollection.list.mockResolvedValue([{ tenantId: 1, orderId: 2 }]); - await port.getRecord('orders', [1, 2]); + await port.getRecord({ collection: 'orders', ids: [1, 2] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { @@ -110,13 +110,15 @@ describe('AgentClientAgentPort', () => { it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); - await expect(port.getRecord('users', [999])).rejects.toThrow(RecordNotFoundError); + await expect(port.getRecord({ collection: 'users', ids: [999] })).rejects.toThrow( + RecordNotFoundError, + ); }); - it('should pass fields to list when fieldNames is provided', async () => { + it('should pass fields to list when fields is provided', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42], ['id', 'name']); + await port.getRecord({ collection: 'users', ids: [42], fields: ['id', 'name'] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -125,10 +127,10 @@ describe('AgentClientAgentPort', () => { }); }); - it('should not pass fields to list when fieldNames is an empty array', async () => { + it('should not pass fields to list when fields is an empty array', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42], []); + await port.getRecord({ collection: 'users', ids: [42], fields: [] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -136,10 +138,10 @@ describe('AgentClientAgentPort', () => { }); }); - it('should not pass fields to list when fieldNames is undefined', async () => { + it('should not pass fields to list when fields is undefined', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42]); + await port.getRecord({ collection: 'users', ids: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -150,7 +152,7 @@ describe('AgentClientAgentPort', () => { it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRecord('unknown', [1]); + const result = await port.getRecord({ collection: 'unknown', ids: [1] }); expect(mockCollection.list).toHaveBeenCalledWith( expect.objectContaining({ @@ -165,7 +167,11 @@ describe('AgentClientAgentPort', () => { it('should call update with pipe-encoded id and return a RecordData', async () => { mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' }); - const result = await port.updateRecord('users', [42], { name: 'Bob' }); + const result = await port.updateRecord({ + collection: 'users', + ids: [42], + values: { name: 'Bob' }, + }); expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); expect(result).toEqual({ @@ -178,7 +184,7 @@ describe('AgentClientAgentPort', () => { it('should encode composite PK to pipe format for update', async () => { mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); - await port.updateRecord('orders', [1, 2], { status: 'done' }); + await port.updateRecord({ collection: 'orders', ids: [1, 2], values: { status: 'done' } }); expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); }); @@ -191,7 +197,12 @@ describe('AgentClientAgentPort', () => { { id: 11, title: 'Post B' }, ]); - const result = await port.getRelatedData('users', [42], 'posts'); + const result = await port.getRelatedData({ + collection: 'users', + ids: [42], + relation: 'posts', + limit: null, + }); expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); expect(result).toEqual([ @@ -208,10 +219,33 @@ describe('AgentClientAgentPort', () => { ]); }); + it('should apply pagination when limit is a number', async () => { + mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); + + await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: 5 }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.objectContaining({ pagination: { size: 5, number: 1 } }), + ); + }); + + it('should not apply pagination when limit is null', async () => { + mockRelation.list.mockResolvedValue([]); + + await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: null }); + + expect(mockRelation.list).toHaveBeenCalledWith({}); + }); + it('should fallback to relationName when no CollectionSchema exists', async () => { mockRelation.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRelatedData('users', [42], 'unknownRelation'); + const result = await port.getRelatedData({ + collection: 'users', + ids: [42], + relation: 'unknownRelation', + limit: null, + }); expect(result[0].collectionName).toBe('unknownRelation'); expect(result[0].recordId).toEqual([1]); @@ -220,26 +254,72 @@ describe('AgentClientAgentPort', () => { it('should return an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); - expect(await port.getRelatedData('users', [42], 'posts')).toEqual([]); + expect( + await port.getRelatedData({ + collection: 'users', + ids: [42], + relation: 'posts', + limit: null, + }), + ).toEqual([]); + }); + + it('should forward fields to the list call when provided', async () => { + mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); + + await port.getRelatedData({ + collection: 'users', + ids: [42], + relation: 'posts', + limit: null, + fields: ['title'], + }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['title'] }), + ); + }); + + it('should omit fields from the list call when not provided', async () => { + mockRelation.list.mockResolvedValue([{ id: 10 }]); + + await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: null }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.not.objectContaining({ fields: expect.anything() }), + ); }); }); describe('executeAction', () => { - it('should encode recordIds to pipe format and call execute', async () => { + it('should encode ids to pipe format and call execute', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); - const result = await port.executeAction('users', 'sendEmail', [[1], [2]]); + const result = await port.executeAction({ + collection: 'users', + action: 'sendEmail', + ids: [1], + }); - expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1'] }); expect(result).toEqual({ success: 'done' }); }); + it('should call execute with empty recordIds when ids is not provided', async () => { + mockAction.execute.mockResolvedValue(undefined); + + await port.executeAction({ collection: 'users', action: 'archive' }); + + expect(mockCollection.action).toHaveBeenCalledWith('archive', { recordIds: [] }); + expect(mockAction.execute).toHaveBeenCalled(); + }); + it('should propagate errors from action execution', async () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); - await expect(port.executeAction('users', 'sendEmail', [[1]])).rejects.toThrow( - 'Action failed', - ); + await expect( + port.executeAction({ collection: 'users', action: 'sendEmail', ids: [1] }), + ).rejects.toThrow('Action failed'); }); }); }); 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 ee1af98642..32b91cfff6 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -14,15 +14,13 @@ import { StepType } from '../../src/types/step-definition'; /** Concrete subclass that exposes protected methods for testing. */ class TestableExecutor extends BaseStepExecutor { - constructor( - context: ExecutionContext, - private readonly errorToThrow?: unknown, - ) { + constructor(context: ExecutionContext, private readonly errorToThrow?: unknown) { super(context); } protected async doExecute(): Promise { if (this.errorToThrow !== undefined) throw this.errorToThrow; + return this.buildOutcomeResult({ status: 'success' }); } 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 new file mode 100644 index 0000000000..141eabd0b4 --- /dev/null +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -0,0 +1,1472 @@ +import type { AgentPort } from '../../src/ports/agent-port'; +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/record'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; +import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-execution-data'; + +import { StepPersistenceError } from '../../src/errors'; +import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { + return { + type: StepType.LoadRelatedRecord, + prompt: 'Load the related order for this customer', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeRelatedRecordData(overrides: Partial = {}): RecordData { + return { + collectionName: 'orders', + recordId: [99], + values: { total: 150 }, + ...overrides, + }; +} + +function makeMockAgentPort(relatedData: RecordData[] = [makeRelatedRecordData()]): AgentPort { + return { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn().mockResolvedValue(relatedData), + executeAction: jest.fn(), + } as unknown as AgentPort; +} + +/** Default schema: 'Order' is BelongsTo (single record), 'Address' is HasMany. */ +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'order', displayName: 'Order', isRelationship: true, relationType: 'BelongsTo' }, + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + actions: [], + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest + .fn() + .mockImplementation((name: string) => + Promise.resolve( + schemasByCollection[name] ?? makeCollectionSchema({ collectionName: name }), + ), + ), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolCallArgs?: Record, toolName = 'select-relation') { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'load-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + remoteTools: [], + ...overrides, + }; +} + +/** Builds a valid pending execution for Branch A tests. */ +function makePendingExecution( + overrides: Partial = {}, +): LoadRelatedRecordStepExecutionData { + return { + type: 'load-related-record', + stepIndex: 0, + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedRecordId: [99], + suggestedFields: ['status', 'amount'], + }, + selectedRecordRef: makeRecordRef(), + ...overrides, + }; +} + +describe('LoadRelatedRecordStepExecutor', () => { + describe('automaticExecution: 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' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + relation: 'order', + limit: 1, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + stepIndex: 0, + executionParams: { displayName: 'Order', name: 'order' }, + executionResult: { + record: expect.objectContaining({ + collectionName: 'orders', + recordId: [99], + stepIndex: 0, + }), + }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), + }), + ); + }); + }); + + describe('automaticExecution: HasMany — 2 AI calls (Branch B)', () => { + it('runs selectRelevantFields + selectBestRecord to pick the best candidate', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { fieldName: 'name', displayName: 'Name', isRelationship: false }, + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [ + { fieldName: 'city', displayName: 'City', isRelationship: false }, + { fieldName: 'zip', displayName: 'Zip', isRelationship: false }, + ], + }); + + // Call 1: select-relation → Address; Call 2: select-fields → ['City'] (displayName); + // Call 3: select-record-by-content → index 1 (Lyon) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Lyon is relevant' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: hasManySchema, + addresses: addressSchema, + }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).toHaveBeenCalledTimes(3); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-fields'); + expect(bindTools.mock.calls[2][0][0].name).toBe('select-record-by-content'); + + // Fetches 50 candidates (HasMany) + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + relation: 'address', + limit: 50, + }); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { + record: expect.objectContaining({ collectionName: 'addresses', recordId: [2] }), + }, + }), + ); + }); + + it('skips field-selection AI call when related collection has no non-relation fields', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: {} }, + { collectionName: 'addresses', recordId: [2], values: {} }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [], + }); + + // Call 1: select-relation; Call 2: select-record-by-content (no select-fields) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'First is best' }, + id: 'c2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).toHaveBeenCalledTimes(2); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-record-by-content'); + }); + + it('takes the single candidate directly without AI record-selection calls', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const agentPort = makeMockAgentPort([ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + ]); + + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load address' }, + id: 'c1', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Only select-relation was called — no field/record AI calls for single candidate + expect(bindTools).toHaveBeenCalledTimes(1); + }); + + it('returns error outcome when AI selects an out-of-range record index', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + + // Call 1: select-relation; Call 2: select-fields; Call 3: out-of-range index 999 + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['city'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 999, reasoning: 'Out of range' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'AI selected record index 999 which is out of range (0-1)', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns empty fieldNames violating the min:1 constraint', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + + // Call 1: select-relation; Call 2: select-fields returns empty array (AI violation) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: [] }, id: 'c2' }], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'AI returned no field names for field selection in collection "addresses"', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('automaticExecution: HasOne — load direct (Branch B)', () => { + it('fetches 1 related record (same path as BelongsTo) and returns success', async () => { + const hasOneSchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'profile', + displayName: 'Profile', + isRelationship: true, + relationType: 'HasOne', + }, + ], + }); + const agentPort = makeMockAgentPort([ + { collectionName: 'profiles', recordId: [5], values: {} }, + ]); + const mockModel = makeMockModel({ relationName: 'Profile', reasoning: 'Load profile' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasOneSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // HasOne uses the same fetchFirstCandidate path as BelongsTo — limit: 1 + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + relation: 'profile', + limit: 1, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { + record: expect.objectContaining({ collectionName: 'profiles', recordId: [5] }), + }, + }), + ); + }); + }); + + describe('without automaticExecution: 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' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + relation: 'order', + limit: 50, + }); + // Single record → only select-relation AI call + expect(mockModel.bindTools).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + stepIndex: 0, + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedRecordId: [99], + suggestedFields: [], + }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + + it('runs field-selection + record-selection AI calls when multiple related records exist', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'orders', recordId: [1], values: { status: 'pending' } }, + { collectionName: 'orders', recordId: [2], values: { status: 'completed' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'status', displayName: 'Status', isRelationship: false }], + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'Load order' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['Status'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Completed is best' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(3); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedRecordId: [2], // record at index 1 + suggestedFields: ['status'], + }, + }), + ); + }); + + it('skips field-selection AI call when related collection has no non-relation fields', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'orders', recordId: [1], values: {} }, + { collectionName: 'orders', recordId: [2], values: {} }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [], + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'Load order' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'First' }, + id: 'c2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // select-relation + select-record-by-content (no select-fields) + expect(bindTools).toHaveBeenCalledTimes(2); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + suggestedRecordId: [1], + suggestedFields: [], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('uses suggestedRecordId when selectedRecordId is absent, no getRelatedData call', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution(); // suggestedRecordId: [99], no selectedRecordId + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + executionParams: { displayName: 'Order', name: 'order' }, + executionResult: { + record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), + }, + pendingData: expect.objectContaining({ + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedRecordId: [99], + }), + }), + ); + }); + + it('uses selectedRecordId over suggestedRecordId when the user overrides the suggestion', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution({ + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedRecordId: [99], + suggestedFields: ['status', 'amount'], + selectedRecordId: [42], + }, + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { + record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), + }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the load when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: false }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: expect.objectContaining({ displayName: 'Order', name: 'order' }), + }), + ); + }); + }); + + describe('no pending data in confirmation flow (Branch A)', () => { + it('returns error outcome when no execution record is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'error', + error: 'No execution record found for step at index 0', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but pendingData is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'error', + error: 'Step at index 0 has no pending data', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('NoRelationshipFieldsError', () => { + it('returns error when collection has no relationship fields', async () => { + const schema = makeCollectionSchema({ + fields: [{ fieldName: 'email', displayName: 'Email', isRelationship: false }], + }); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'No relationship fields on record from collection "customers"', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('RelatedRecordNotFoundError', () => { + it('returns error when BelongsTo getRelatedData returns empty array (Branch B)', async () => { + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'No related record found for relation "order" on collection "customers"', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when HasMany getRelatedData returns empty array (Branch B)', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'No related record found for relation "address" on collection "customers"', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when getRelatedData returns empty array (Branch C)', async () => { + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'No related record found for relation "order" on collection "customers"', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('StepPersistenceError post-load', () => { + it('returns error outcome when saveStepExecution fails after load (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runId: 'run-1', + stepIndex: 0, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain( + 'Related record loaded but step state could not be persisted', + ); + expect(result.stepOutcome.error).toContain('run "run-1", step 0'); + }); + + it('returns error outcome when saveStepExecution fails after load (Branch A confirmed)', async () => { + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runId: 'run-1', stepIndex: 0, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain( + 'Related record loaded but step state could not be persisted', + ); + expect(result.stepOutcome.error).toContain('run "run-1", step 0'); + }); + }); + + describe('resolveRelationName failure', () => { + it('returns error when AI returns a relation name not found in the schema', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ relationName: 'NonExistentRelation', reasoning: 'test' }); + const schema = makeCollectionSchema({ + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'Relation "NonExistentRelation" not found in collection "customers"', + ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + }); + }); + + describe('AI malformed/missing tool call', () => { + it('returns error on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'select-relation', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('load-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'AI returned a malformed tool call for "select-relation": JSON parse error', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns no tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('load-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('infra error propagation', () => { + it('lets getRelatedData infrastructure errors propagate (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + + it('lets getRelatedData infrastructure errors propagate (Branch C)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ model: mockModel.model, agentPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + }); + + describe('multi-record AI selection (base record pool)', () => { + it('uses AI to select among multiple base records then loads relation', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'invoice', + displayName: 'Invoice', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + + // Call 1: select-record; Call 2: select-relation + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Invoice', reasoning: 'Load the invoice' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const agentPort = makeMockAgentPort([ + { collectionName: 'invoices', recordId: [55], values: {} }, + ]); + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(2); + + const selectRecordTool = bindTools.mock.calls[0][0][0]; + expect(selectRecordTool.name).toBe('select-record'); + + const selectRelationTool = bindTools.mock.calls[1][0][0]; + expect(selectRelationTool.name).toBe('select-relation'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + displayName: 'Invoice', + name: 'invoice', + relatedCollectionName: 'invoices', + suggestedRecordId: [55], + }), + selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders' }), + }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in select-relation messages', async () => { + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new LoadRelatedRecordStepExecutor({ + ...context, + stepId: 'load-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps message + system prompt + collection info + human message = 4 + expect(messages).toHaveLength(4); + expect(messages[0].content).toContain('Should we proceed?'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('loading a related record'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[mockModel.invoke.mock.calls.length - 1][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Load the relevant related record.'); + }); + }); + + describe('RunStore error propagation', () => { + it('lets getStepExecutions errors propagate (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('DB timeout'); + }); + + it('lets saveStepExecution errors propagate when saving awaiting-input (Branch C)', async () => { + const agentPort = makeMockAgentPort(); + const rawError = new Error('Disk full'); + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(rawError), + }); + const context = makeContext({ agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + // Branch C propagates the raw error directly — it is NOT wrapped in StepPersistenceError, + // preserving retry-safety (the relation-load has not yet happened). + const error = await executor.execute().catch(e => e); + expect(error).toBe(rawError); + expect(error).not.toBeInstanceOf(StepPersistenceError); + }); + + it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore, userConfirmed: false }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + }); + + describe('displayName → fieldName resolution fallback', () => { + it('resolves relation when AI returns technical name instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns technical name 'order' instead of display name 'Order' + const mockModel = makeMockModel({ relationName: 'order', reasoning: 'fallback' }); + const schema = makeCollectionSchema({ + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + relation: 'order', + limit: 1, + }); + }); + }); + + describe('schema caching', () => { + it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { + const workflowPort = makeMockWorkflowPort(); + const context = makeContext({ + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAvailableRecordRefs filtering', () => { + it('excludes a pending load-related-record (no record field) from the record pool', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + // A completed load-related-record (has record) — should appear in pool + const completedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + // A pending load-related-record (no record — awaiting-input state) — should be excluded + const pendingExecution = { + type: 'load-related-record' as const, + stepIndex: 3, + selectedRecordRef: makeRecordRef(), + pendingData: { + displayName: 'Invoice', + name: 'invoice', + relatedCollectionName: 'invoices', + suggestedRecordId: [55], + }, + }; + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + + // Call 1: select-record (picks the completed related record) + // Call 2: select-relation + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'test' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: completedRecord, + selectedRecordRef: makeRecordRef(), + }, + pendingExecution, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + // Pool = [base, completedRecord] = 2 items → select-record IS invoked + // Pool does NOT include pending execution (no record) → only 2 options, not 3 + expect(bindTools).toHaveBeenCalledTimes(2); + const selectRecordTool = bindTools.mock.calls[0][0][0]; + expect(selectRecordTool.name).toBe('select-record'); + expect(selectRecordTool.schema.shape.recordIdentifier.options).toHaveLength(2); + expect(selectRecordTool.schema.shape.recordIdentifier.options).not.toContain( + expect.stringContaining('stepIndex: 3'), + ); + }); + }); +}); 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 e6854c7157..abd7f27cf3 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 @@ -34,8 +34,8 @@ function makeMockAgentPort( return { getRecord: jest .fn() - .mockImplementation((collectionName: string) => - Promise.resolve(recordsByCollection[collectionName] ?? { values: {} }), + .mockImplementation(({ collection }: { collection: string }) => + Promise.resolve(recordsByCollection[collection] ?? { values: {} }), ), updateRecord: jest.fn(), getRelatedData: jest.fn(), @@ -204,7 +204,11 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(); - expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['name', 'email']); + expect(agentPort.getRecord).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + fields: ['name', 'email'], + }); }); it('passes only resolved field names when some fields are unresolved', async () => { @@ -216,7 +220,11 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(); - expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['email']); + expect(agentPort.getRecord).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + fields: ['email'], + }); }); it('returns error when no fields can be resolved', async () => { @@ -361,11 +369,14 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -445,11 +456,14 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -516,11 +530,14 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 5, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 5, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -566,11 +583,14 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 1, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 1, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), 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 0cb3620a7f..9b72e4a62d 100644 --- a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -6,7 +6,7 @@ import type { CollectionSchema, RecordRef } from '../../src/types/record'; import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import type { TriggerRecordActionStepExecutionData } from '../../src/types/step-execution-data'; -import { WorkflowExecutorError } from '../../src/errors'; +import { StepStateError } from '../../src/errors'; import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -132,9 +132,11 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'send-welcome-email', [ - [42], - ]); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'send-welcome-email', + ids: [42], + }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -210,9 +212,11 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'send-welcome-email', [ - [42], - ]); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'send-welcome-email', + ids: [42], + }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -398,7 +402,7 @@ describe('TriggerRecordActionStepExecutor', () => { it('returns error when executeAction throws WorkflowExecutorError', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue( - new WorkflowExecutorError('Action not permitted'), + new StepStateError('Action not permitted'), ); const mockModel = makeMockModel({ actionName: 'Send Welcome Email', @@ -428,7 +432,7 @@ describe('TriggerRecordActionStepExecutor', () => { it('returns error when executeAction throws WorkflowExecutorError during confirmation', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue( - new WorkflowExecutorError('Action not permitted'), + new StepStateError('Action not permitted'), ); const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', @@ -516,7 +520,11 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'archive', [[42]]); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'archive', + ids: [42], + }); }); it('resolves action when AI returns technical name instead of displayName', async () => { @@ -541,7 +549,11 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.executeAction).toHaveBeenCalledWith('customers', 'archive', [[42]]); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'archive', + ids: [42], + }); }); }); @@ -585,11 +597,14 @@ describe('TriggerRecordActionStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), 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 286fea68f5..85d8db0cfe 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -6,7 +6,7 @@ import type { CollectionSchema, RecordRef } from '../../src/types/record'; import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import type { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; -import { WorkflowExecutorError } from '../../src/errors'; +import { StepStateError } from '../../src/errors'; import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -140,7 +140,11 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + values: { status: 'active' }, + }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -209,7 +213,11 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + values: { status: 'active' }, + }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -370,11 +378,14 @@ describe('UpdateRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + record: relatedRecord, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -542,9 +553,7 @@ describe('UpdateRecordStepExecutor', () => { describe('agentPort.updateRecord WorkflowExecutorError (Branch B)', () => { it('returns error when updateRecord throws WorkflowExecutorError', async () => { const agentPort = makeMockAgentPort(); - (agentPort.updateRecord as jest.Mock).mockRejectedValue( - new WorkflowExecutorError('Record locked'), - ); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new StepStateError('Record locked')); const mockModel = makeMockModel({ fieldName: 'Status', value: 'active', @@ -572,9 +581,7 @@ describe('UpdateRecordStepExecutor', () => { describe('agentPort.updateRecord WorkflowExecutorError (Branch A)', () => { it('returns error when updateRecord throws WorkflowExecutorError during confirmation', async () => { const agentPort = makeMockAgentPort(); - (agentPort.updateRecord as jest.Mock).mockRejectedValue( - new WorkflowExecutorError('Record locked'), - ); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new StepStateError('Record locked')); const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, @@ -668,7 +675,11 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + ids: [42], + values: { status: 'active' }, + }); }); }); From a83c6eed89fc7966751467a8bfc3eb305cda8284 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 13:33:22 +0100 Subject: [PATCH 11/34] feat(workflow-executor): add userMessage to error hierarchy for end-user facing messages Separates technical error messages (dev logs) from user-facing messages (Forest Admin UI). WorkflowExecutorError now carries a readonly `userMessage` field; base-step-executor uses it instead of `message` when building the step outcome error. Each subclass declares its own user-oriented message. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/CLAUDE.md | 1 + packages/workflow-executor/src/errors.ts | 75 +++++++++++++++---- .../src/executors/base-step-executor.ts | 15 +++- .../test/executors/base-step-executor.test.ts | 50 ++++++++++++- .../executors/condition-step-executor.test.ts | 10 ++- .../load-related-record-step-executor.test.ts | 68 ++++++++--------- .../read-record-step-executor.test.ts | 37 +++++---- ...rigger-record-action-step-executor.test.ts | 58 +++++++------- .../update-record-step-executor.test.ts | 54 +++++++------ 9 files changed, 247 insertions(+), 121 deletions(-) diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 1bb8c3326f..dbea279bd1 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -77,6 +77,7 @@ src/ - **Ports (IO injection)** — All external IO goes through injected port interfaces, keeping the core pure and testable. - **AI integration** — Uses `@langchain/core` (`BaseChatModel`, `DynamicStructuredTool`) for AI-powered steps. `ExecutionContext.model` is a `BaseChatModel`. - **Error hierarchy** — All domain errors must extend `WorkflowExecutorError` (defined in `src/errors.ts`). This ensures executors can distinguish domain errors (caught → error outcome) from infrastructure errors (uncaught → propagate to caller). Never throw a plain `Error` for a domain error case. +- **Dual error messages** — `WorkflowExecutorError` carries two messages: `message` (technical, for dev logs) and `userMessage` (human-readable, surfaced to the Forest Admin UI via `stepOutcome.error`). The mapping happens in a single place: `base-step-executor.ts` uses `error.userMessage` when building the error outcome. When adding a new error subclass, always provide a distinct `userMessage` oriented toward end-users (no collection names, field names, or AI internals). If `userMessage` is omitted in the constructor call, it falls back to `message`. - **displayName in AI tools** — All `DynamicStructuredTool` schemas and system message prompts must use `displayName`, never `fieldName`. `displayName` is a Forest Admin frontend feature that replaces the technical field/relation/action name with a product-oriented label configured by the Forest Admin admin. End users write their workflow prompts using these display names, not the underlying technical names. After an AI tool call returns display names, map them back to `fieldName`/`name` before using them in datasource operations (e.g. filtering record values, calling `getRecord`). - **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 422c2413de..efe3cd85ec 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -1,15 +1,21 @@ /* eslint-disable max-classes-per-file */ export abstract class WorkflowExecutorError extends Error { - constructor(message: string) { + readonly userMessage: string; + + constructor(message: string, userMessage?: string) { super(message); this.name = this.constructor.name; + this.userMessage = userMessage ?? message; } } export class MissingToolCallError extends WorkflowExecutorError { constructor() { - super('AI did not return a tool call'); + super( + 'AI did not return a tool call', + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); } } @@ -17,14 +23,20 @@ export class MalformedToolCallError extends WorkflowExecutorError { readonly toolName: string; constructor(toolName: string, details: string) { - super(`AI returned a malformed tool call for "${toolName}": ${details}`); + super( + `AI returned a malformed tool call for "${toolName}": ${details}`, + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); this.toolName = toolName; } } export class RecordNotFoundError extends WorkflowExecutorError { constructor(collectionName: string, recordId: string) { - super(`Record not found: collection "${collectionName}", id "${recordId}"`); + super( + `Record not found: collection "${collectionName}", id "${recordId}"`, + 'The record no longer exists. It may have been deleted.', + ); } } @@ -36,25 +48,37 @@ export class NoRecordsError extends WorkflowExecutorError { export class NoReadableFieldsError extends WorkflowExecutorError { constructor(collectionName: string) { - super(`No readable fields on record from collection "${collectionName}"`); + super( + `No readable fields on record from collection "${collectionName}"`, + 'This record type has no readable fields configured in Forest Admin.', + ); } } export class NoResolvedFieldsError extends WorkflowExecutorError { constructor(fieldNames: string[]) { - super(`None of the requested fields could be resolved: ${fieldNames.join(', ')}`); + super( + `None of the requested fields could be resolved: ${fieldNames.join(', ')}`, + "The AI selected fields that don't exist on this record. Try rephrasing the step's prompt.", + ); } } export class NoWritableFieldsError extends WorkflowExecutorError { constructor(collectionName: string) { - super(`No writable fields on record from collection "${collectionName}"`); + super( + `No writable fields on record from collection "${collectionName}"`, + 'This record type has no editable fields configured in Forest Admin.', + ); } } export class NoActionsError extends WorkflowExecutorError { constructor(collectionName: string) { - super(`No actions available on collection "${collectionName}"`); + super( + `No actions available on collection "${collectionName}"`, + 'No actions are available on this record.', + ); } } @@ -68,14 +92,17 @@ export class StepPersistenceError extends WorkflowExecutorError { cause?: unknown; constructor(message: string, cause?: unknown) { - super(message); + super(message, 'The step result could not be saved. Please retry.'); if (cause !== undefined) this.cause = cause; } } export class NoRelationshipFieldsError extends WorkflowExecutorError { constructor(collectionName: string) { - super(`No relationship fields on record from collection "${collectionName}"`); + super( + `No relationship fields on record from collection "${collectionName}"`, + 'This record type has no relations configured in Forest Admin.', + ); } } @@ -83,33 +110,51 @@ export class RelatedRecordNotFoundError extends WorkflowExecutorError { constructor(collectionName: string, relationName: string) { super( `No related record found for relation "${relationName}" on collection "${collectionName}"`, + 'The related record could not be found. It may have been deleted.', ); } } /** Thrown when the AI returns a response that violates expected constraints (bad index, empty selection, unknown identifier, etc.). */ -export class InvalidAIResponseError extends WorkflowExecutorError {} +export class InvalidAIResponseError extends WorkflowExecutorError { + constructor(message: string) { + super(message, "The AI made an unexpected choice. Try rephrasing the step's prompt."); + } +} /** Thrown when a named relation is not found in the collection schema. */ export class RelationNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { - super(`Relation "${name}" not found in collection "${collectionName}"`); + super( + `Relation "${name}" not found in collection "${collectionName}"`, + "The AI selected a relation that doesn't exist on this record. Try rephrasing the step's prompt.", + ); } } /** Thrown when a named field is not found in the collection schema. */ export class FieldNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { - super(`Field "${name}" not found in collection "${collectionName}"`); + super( + `Field "${name}" not found in collection "${collectionName}"`, + "The AI selected a field that doesn't exist on this record. Try rephrasing the step's prompt.", + ); } } /** Thrown when a named action is not found in the collection schema. */ export class ActionNotFoundError extends WorkflowExecutorError { constructor(name: string, collectionName: string) { - super(`Action "${name}" not found in collection "${collectionName}"`); + super( + `Action "${name}" not found in collection "${collectionName}"`, + "The AI selected an action that doesn't exist on this record. Try rephrasing the step's prompt.", + ); } } /** Thrown when step execution state is invalid (missing execution record, missing pending data, etc.). */ -export class StepStateError extends WorkflowExecutorError {} +export class StepStateError extends WorkflowExecutorError { + constructor(message: string) { + super(message, 'An unexpected error occurred while processing this step.'); + } +} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 2a57c78896..b6bba6539c 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -34,10 +34,21 @@ export default abstract class BaseStepExecutor = {}): ExecutionContext { return { runId: 'run-1', @@ -97,6 +102,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: makeMockLogger(), ...overrides, }; } @@ -483,10 +489,48 @@ describe('BaseStepExecutor', () => { expect(result.stepOutcome.error).toBe('No records available'); }); - it('rethrows non-WorkflowExecutorError errors', async () => { - const executor = new TestableExecutor(makeContext(), new Error('infrastructure failure')); + describe('unexpected error handling', () => { + it('returns error outcome instead of rethrowing', async () => { + const executor = new TestableExecutor(makeContext(), new Error('db connection refused')); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Unexpected error during step execution'); + }); + + it('logs the full error context when logger is provided', async () => { + const logger = makeMockLogger(); + const executor = new TestableExecutor( + makeContext({ logger }), + new Error('db connection refused'), + ); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-0', + stepIndex: 0, + error: 'db connection refused', + }), + ); + }); + + it('includes stack trace in log context', async () => { + const logger = makeMockLogger(); + const err = new Error('db connection refused'); + const executor = new TestableExecutor(makeContext({ logger }), err); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ stack: err.stack }), + ); + }); - await expect(executor.execute()).rejects.toThrow('infrastructure failure'); + it('handles non-Error throwables without crashing', async () => { + const executor = new TestableExecutor(makeContext(), 'a raw string thrown'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); }); }); 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 7a4c5fa6b9..3f1bbe5402 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -55,6 +55,7 @@ function makeContext( runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -255,14 +256,14 @@ describe('ConditionStepExecutor', () => { expect(result.stepOutcome.type).toBe('condition'); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "choose-gateway-option": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('error propagation', () => { - it('lets infrastructure errors propagate', async () => { + it('returns error outcome for infrastructure errors', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); const runStore = makeMockRunStore(); @@ -272,7 +273,8 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('API timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -290,7 +292,7 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain('Condition step state could not be persisted'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); }); }); 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 141eabd0b4..1935cf35b0 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -6,7 +6,6 @@ import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/re import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-execution-data'; -import { StepPersistenceError } from '../../src/errors'; import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -118,6 +117,7 @@ function makeContext( runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -450,7 +450,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI selected record index 999 which is out of range (0-1)', + "The AI made an unexpected choice. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -509,7 +509,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned no field names for field selection in collection "addresses"', + "The AI made an unexpected choice. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -838,7 +838,7 @@ describe('LoadRelatedRecordStepExecutor', () => { stepId: 'load-1', stepIndex: 0, status: 'error', - error: 'No execution record found for step at index 0', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -863,7 +863,7 @@ describe('LoadRelatedRecordStepExecutor', () => { stepId: 'load-1', stepIndex: 0, status: 'error', - error: 'Step at index 0 has no pending data', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -885,7 +885,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No relationship fields on record from collection "customers"', + 'This record type has no relations configured in Forest Admin.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -908,7 +908,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No related record found for relation "order" on collection "customers"', + 'The related record could not be found. It may have been deleted.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -940,7 +940,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No related record found for relation "address" on collection "customers"', + 'The related record could not be found. It may have been deleted.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -956,7 +956,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No related record found for relation "order" on collection "customers"', + 'The related record could not be found. It may have been deleted.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -978,10 +978,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain( - 'Related record loaded but step state could not be persisted', - ); - expect(result.stepOutcome.error).toContain('run "run-1", step 0'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); it('returns error outcome when saveStepExecution fails after load (Branch A confirmed)', async () => { @@ -996,10 +993,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain( - 'Related record loaded but step state could not be persisted', - ); - expect(result.stepOutcome.error).toContain('run "run-1", step 0'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); }); @@ -1032,7 +1026,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'Relation "NonExistentRelation" not found in collection "customers"', + "The AI selected a relation that doesn't exist on this record. Try rephrasing the step's prompt.", ); expect(agentPort.getRelatedData).not.toHaveBeenCalled(); }); @@ -1061,7 +1055,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "select-relation": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -1082,13 +1076,15 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('load-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('infra error propagation', () => { - it('lets getRelatedData infrastructure errors propagate (Branch B)', async () => { + it('returns error outcome for getRelatedData infrastructure errors (Branch B)', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); @@ -1099,17 +1095,19 @@ describe('LoadRelatedRecordStepExecutor', () => { }); const executor = new LoadRelatedRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets getRelatedData infrastructure errors propagate (Branch C)', async () => { + it('returns error outcome for getRelatedData infrastructure errors (Branch C)', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new LoadRelatedRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -1288,33 +1286,30 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('RunStore error propagation', () => { - it('lets getStepExecutions errors propagate (Branch A)', async () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), }); const context = makeContext({ runStore, userConfirmed: true }); const executor = new LoadRelatedRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('DB timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when saving awaiting-input (Branch C)', async () => { + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { const agentPort = makeMockAgentPort(); - const rawError = new Error('Disk full'); const runStore = makeMockRunStore({ - saveStepExecution: jest.fn().mockRejectedValue(rawError), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); const context = makeContext({ agentPort, runStore }); const executor = new LoadRelatedRecordStepExecutor(context); - // Branch C propagates the raw error directly — it is NOT wrapped in StepPersistenceError, - // preserving retry-safety (the relation-load has not yet happened). - const error = await executor.execute().catch(e => e); - expect(error).toBe(rawError); - expect(error).not.toBeInstanceOf(StepPersistenceError); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { const execution = makePendingExecution(); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), @@ -1323,7 +1318,8 @@ describe('LoadRelatedRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed: false }); const executor = new LoadRelatedRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); 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 abd7f27cf3..1d4145f4a2 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 @@ -113,6 +113,7 @@ function makeContext( runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -238,7 +239,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'None of the requested fields could be resolved: nonexistent, unknown', + "The AI selected fields that don't exist on this record. Try rephrasing the step's prompt.", ); expect(agentPort.getRecord).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -323,7 +324,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No readable fields on record from collection "customers"', + 'This record type has no readable fields configured in Forest Admin.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -603,7 +604,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI selected record "NonExistent #999" which does not match any available record', + "The AI made an unexpected choice. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -623,23 +624,26 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Record not found: collection "customers", id "42"'); + expect(result.stepOutcome.error).toBe( + 'The record no longer exists. It may have been deleted.', + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('lets infrastructure errors propagate', async () => { + it('returns error outcome for infrastructure errors', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); describe('model error', () => { - it('lets non-WorkflowExecutorError propagate from AI invocation', async () => { + it('returns error outcome for non-WorkflowExecutorError from AI invocation', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); const context = makeContext({ @@ -647,7 +651,8 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('API timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -671,7 +676,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "read-selected-record-fields": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -689,13 +694,15 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('RunStore error propagation', () => { - it('lets saveStepExecution errors propagate', async () => { + it('returns error outcome when saveStepExecution fails', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), @@ -703,10 +710,11 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Storage full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets getStepExecutions errors propagate', async () => { + it('returns error outcome when getStepExecutions fails', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('Connection lost')), @@ -714,7 +722,8 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection lost'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); 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 9b72e4a62d..0efef7002a 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 @@ -108,6 +108,7 @@ function makeContext( runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -286,7 +287,7 @@ describe('TriggerRecordActionStepExecutor', () => { stepId: 'trigger-1', stepIndex: 0, status: 'error', - error: 'No execution record found for step at index 0', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -313,7 +314,7 @@ describe('TriggerRecordActionStepExecutor', () => { stepId: 'trigger-1', stepIndex: 0, status: 'error', - error: 'No execution record found for step at index 0', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -339,7 +340,7 @@ describe('TriggerRecordActionStepExecutor', () => { stepId: 'trigger-1', stepIndex: 0, status: 'error', - error: 'Step at index 0 has no pending data', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -361,7 +362,7 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('No actions available on collection "customers"'); + expect(result.stepOutcome.error).toBe('No actions are available on this record.'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -391,7 +392,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'Action "NonExistentAction" not found in collection "customers"', + "The AI selected an action that doesn't exist on this record. Try rephrasing the step's prompt.", ); expect(agentPort.executeAction).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -423,7 +424,9 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('trigger-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Action not permitted'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -456,13 +459,15 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('trigger-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Action not permitted'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('agentPort.executeAction infra error', () => { - it('lets infrastructure errors propagate (Branch B)', async () => { + it('returns error outcome for infrastructure errors (Branch B)', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ @@ -476,10 +481,11 @@ describe('TriggerRecordActionStepExecutor', () => { }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets infrastructure errors propagate (Branch A)', async () => { + it('returns error outcome for infrastructure errors (Branch A)', async () => { const agentPort = makeMockAgentPort(); (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); const execution: TriggerRecordActionStepExecutionData = { @@ -498,7 +504,8 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -692,7 +699,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "select-action": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -713,13 +720,15 @@ describe('TriggerRecordActionStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('trigger-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('RunStore error propagation', () => { - it('lets getStepExecutions errors propagate (Branch A)', async () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), }); @@ -727,10 +736,11 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('DB timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, @@ -748,17 +758,19 @@ describe('TriggerRecordActionStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when saving awaiting-input (Branch C)', async () => { + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); const context = makeContext({ runStore }); const executor = new TriggerRecordActionStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); it('returns error outcome after successful executeAction when saveStepExecution fails (Branch B)', async () => { @@ -774,9 +786,7 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain( - 'Action "send-welcome-email" executed but step state could not be persisted', - ); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); it('returns error outcome after successful executeAction when saveStepExecution fails (Branch A confirmed)', async () => { @@ -800,9 +810,7 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain( - 'Action "send-welcome-email" executed but step state could not be persisted', - ); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); }); 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 85d8db0cfe..2f9da2768e 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 @@ -114,6 +114,7 @@ function makeContext( runStore: makeMockRunStore(), previousSteps: [], remoteTools: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -275,7 +276,7 @@ describe('UpdateRecordStepExecutor', () => { stepId: 'update-1', stepIndex: 0, status: 'error', - error: 'No execution record found for step at index 0', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -302,7 +303,7 @@ describe('UpdateRecordStepExecutor', () => { stepId: 'update-1', stepIndex: 0, status: 'error', - error: 'No execution record found for step at index 0', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -328,7 +329,7 @@ describe('UpdateRecordStepExecutor', () => { stepId: 'update-1', stepIndex: 0, status: 'error', - error: 'Step at index 0 has no pending data', + error: 'An unexpected error occurred while processing this step.', }, }); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -441,7 +442,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No writable fields on record from collection "customers"', + 'This record type has no editable fields configured in Forest Admin.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -465,7 +466,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'Field "NonExistentField" not found in collection "customers"', + "The AI selected a field that doesn't exist on this record. Try rephrasing the step's prompt.", ); }); }); @@ -524,7 +525,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "update-record-field": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -545,7 +546,9 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('update-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -574,7 +577,9 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('update-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Record locked'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); }); }); @@ -601,12 +606,14 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.stepId).toBe('update-1'); expect(result.stepOutcome.stepIndex).toBe(0); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Record locked'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); }); }); describe('agentPort.updateRecord infra error', () => { - it('lets infrastructure errors propagate (Branch B)', async () => { + it('returns error outcome for infrastructure errors (Branch B)', async () => { const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ @@ -621,10 +628,11 @@ describe('UpdateRecordStepExecutor', () => { }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets infrastructure errors propagate (Branch A)', async () => { + it('returns error outcome for infrastructure errors (Branch A)', async () => { const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); const execution: UpdateRecordStepExecutionData = { @@ -640,7 +648,8 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -700,7 +709,7 @@ describe('UpdateRecordStepExecutor', () => { }); describe('RunStore error propagation', () => { - it('lets getStepExecutions errors propagate (Branch A)', async () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), }); @@ -708,10 +717,11 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('DB timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, @@ -726,17 +736,19 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets saveStepExecution errors propagate when saving awaiting-input (Branch C)', async () => { + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); const context = makeContext({ runStore }); const executor = new UpdateRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Disk full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); it('returns error outcome after successful updateRecord when saveStepExecution fails (Branch B)', async () => { @@ -752,9 +764,7 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toContain( - 'Record update persisted but step state could not be saved', - ); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); }); From ff60271182fc99d4a1cbb1acbe3ca4ac7592bf98 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 13:36:54 +0100 Subject: [PATCH 12/34] feat(workflow-executor): add Logger port, ConsoleLogger adapter and inject logger into execution context Introduces a Logger interface (port) and ConsoleLogger (default implementation). Adds logger to ExecutionContext, masks raw error messages from the HTTP API (security), and logs unhandled HTTP errors with context instead. Co-Authored-By: Claude Sonnet 4.6 --- .../workflow-executor/src/adapters/console-logger.ts | 9 +++++++++ .../workflow-executor/src/http/executor-http-server.ts | 10 +++++++++- packages/workflow-executor/src/index.ts | 2 ++ packages/workflow-executor/src/ports/logger-port.ts | 3 +++ packages/workflow-executor/src/types/execution.ts | 2 ++ .../test/http/executor-http-server.test.ts | 4 ++-- 6 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 packages/workflow-executor/src/adapters/console-logger.ts create mode 100644 packages/workflow-executor/src/ports/logger-port.ts diff --git a/packages/workflow-executor/src/adapters/console-logger.ts b/packages/workflow-executor/src/adapters/console-logger.ts new file mode 100644 index 0000000000..41ed7e3fa5 --- /dev/null +++ b/packages/workflow-executor/src/adapters/console-logger.ts @@ -0,0 +1,9 @@ +import type { Logger } from '../ports/logger-port'; + +export default class ConsoleLogger implements Logger { + error(message: string, context: Record): void { + console.error( + JSON.stringify({ level: 'error', message, timestamp: new Date().toISOString(), ...context }), + ); + } +} diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index 72ea42936e..cc96d395bf 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -1,3 +1,4 @@ +import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type Runner from '../runner'; import type { Server } from 'http'; @@ -10,6 +11,7 @@ export interface ExecutorHttpServerOptions { port: number; runStore: RunStore; runner: Runner; + logger?: Logger; } export default class ExecutorHttpServer { @@ -26,8 +28,14 @@ export default class ExecutorHttpServer { try { await next(); } catch (err: unknown) { + this.options.logger?.error('Unhandled HTTP error', { + method: ctx.method, + path: ctx.path, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); ctx.status = 500; - ctx.body = { error: err instanceof Error ? err.message : 'Internal server error' }; + ctx.body = { error: 'Internal server error' }; } }); diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 0347ae0879..8a3bc60952 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -48,6 +48,8 @@ export type { export type { AgentPort, Id, QueryBase, Limit } from './ports/agent-port'; export type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; export type { RunStore } from './ports/run-store'; +export type { Logger } from './ports/logger-port'; +export { default as ConsoleLogger } from './adapters/console-logger'; export { WorkflowExecutorError, diff --git a/packages/workflow-executor/src/ports/logger-port.ts b/packages/workflow-executor/src/ports/logger-port.ts new file mode 100644 index 0000000000..017f8742ab --- /dev/null +++ b/packages/workflow-executor/src/ports/logger-port.ts @@ -0,0 +1,3 @@ +export interface Logger { + error(message: string, context: Record): void; +} diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 25f71fe448..efd759ea5c 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -4,6 +4,7 @@ import type { RecordRef } from './record'; import type { StepDefinition } from './step-definition'; import type { StepOutcome } from './step-outcome'; import type { AgentPort } from '../ports/agent-port'; +import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; @@ -40,4 +41,5 @@ export interface ExecutionContext readonly previousSteps: ReadonlyArray>; readonly remoteTools: readonly unknown[]; readonly userConfirmed?: boolean; + readonly logger: Logger; } 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 f4d415b4c8..4e57d24e4f 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -58,7 +58,7 @@ describe('ExecutorHttpServer', () => { const response = await request(server.callback).get('/runs/run-1'); expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'db error' }); + expect(response.body).toEqual({ error: 'Internal server error' }); }); }); @@ -93,7 +93,7 @@ describe('ExecutorHttpServer', () => { const response = await request(server.callback).post('/runs/run-1/trigger'); expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'poll failed' }); + expect(response.body).toEqual({ error: 'Internal server error' }); }); }); From 8295118e33e7b788f317f26d88e59a0d4c1a40bc Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 13:41:39 +0100 Subject: [PATCH 13/34] refactor(workflow-executor): remove redundant level field from ConsoleLogger output Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/adapters/console-logger.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/workflow-executor/src/adapters/console-logger.ts b/packages/workflow-executor/src/adapters/console-logger.ts index 41ed7e3fa5..cbe989ab33 100644 --- a/packages/workflow-executor/src/adapters/console-logger.ts +++ b/packages/workflow-executor/src/adapters/console-logger.ts @@ -2,8 +2,6 @@ import type { Logger } from '../ports/logger-port'; export default class ConsoleLogger implements Logger { error(message: string, context: Record): void { - console.error( - JSON.stringify({ level: 'error', message, timestamp: new Date().toISOString(), ...context }), - ); + console.error(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context })); } } From b7d414de0592b90f153c12d841c7e5b222d9f5d7 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 18:34:00 +0100 Subject: [PATCH 14/34] =?UTF-8?q?refactor(workflow-executor):=20extract=20?= =?UTF-8?q?StepSummaryBuilder,=20add=20load-related-record=20executor,=20r?= =?UTF-8?q?ename=20ids=E2=86=92id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StepExecutionFormatters (static per-type formatters) and StepSummaryBuilder (orchestrates step summary for AI context); decouple formatting from BaseStepExecutor - Load-related-record executionResult is now self-contained: { relation: RelationRef; record: RecordRef } - Rename ids → id in AgentPort and all callers (id is a composite key of one record, not multiple records) Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/agent-client-agent-port.ts | 36 +- .../src/executors/base-step-executor.ts | 68 +--- .../load-related-record-step-executor.ts | 7 +- .../executors/read-record-step-executor.ts | 2 +- .../executors/step-execution-formatters.ts | 43 +++ .../src/executors/step-summary-builder.ts | 43 +++ .../trigger-record-action-step-executor.ts | 2 +- .../executors/update-record-step-executor.ts | 2 +- .../workflow-executor/src/ports/agent-port.ts | 4 +- .../src/types/step-execution-data.ts | 12 +- .../adapters/agent-client-agent-port.test.ts | 36 +- .../test/executors/base-step-executor.test.ts | 359 ++---------------- .../load-related-record-step-executor.test.ts | 41 +- .../read-record-step-executor.test.ts | 24 +- .../step-execution-formatters.test.ts | 91 +++++ .../executors/step-summary-builder.test.ts | 284 ++++++++++++++ ...rigger-record-action-step-executor.test.ts | 13 +- .../update-record-step-executor.test.ts | 11 +- 18 files changed, 610 insertions(+), 468 deletions(-) create mode 100644 packages/workflow-executor/src/executors/step-execution-formatters.ts create mode 100644 packages/workflow-executor/src/executors/step-summary-builder.ts create mode 100644 packages/workflow-executor/test/executors/step-execution-formatters.test.ts create mode 100644 packages/workflow-executor/test/executors/step-summary-builder.test.ts diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 5dbfd14877..06017640fb 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -6,10 +6,10 @@ import { RecordNotFoundError } from '../errors'; function buildPkFilter( primaryKeyFields: string[], - ids: Array, + id: Array, ): SelectOptions['filters'] { if (primaryKeyFields.length === 1) { - return { field: primaryKeyFields[0], operator: 'Equal', value: ids[0] }; + return { field: primaryKeyFields[0], operator: 'Equal', value: id[0] }; } return { @@ -17,14 +17,14 @@ function buildPkFilter( conditions: primaryKeyFields.map((field, i) => ({ field, operator: 'Equal', - value: ids[i], + value: id[i], })), }; } // agent-client methods (update, relation, action) still expect the pipe-encoded string format -function encodePk(ids: Array): string { - return ids.map(v => String(v)).join('|'); +function encodePk(id: Array): string { + return id.map(v => String(v)).join('|'); } function extractRecordId( @@ -46,32 +46,32 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionSchemas = params.collectionSchemas; } - async getRecord({ collection, ids, fields }: QueryBase) { + async getRecord({ collection, id, fields }: QueryBase) { const schema = this.resolveSchema(collection); const records = await this.client.collection(collection).list>({ - filters: buildPkFilter(schema.primaryKeyFields, ids), + filters: buildPkFilter(schema.primaryKeyFields, id), pagination: { size: 1, number: 1 }, ...(fields?.length && { fields }), }); if (records.length === 0) { - throw new RecordNotFoundError(collection, encodePk(ids)); + throw new RecordNotFoundError(collection, encodePk(id)); } - return { collectionName: collection, recordId: ids, values: records[0] }; + return { collectionName: collection, recordId: id, values: records[0] }; } - async updateRecord({ collection, ids, values }: QueryBase & { values: Record }) { + async updateRecord({ collection, id, values }: QueryBase & { values: Record }) { const updatedRecord = await this.client .collection(collection) - .update>(encodePk(ids), values); + .update>(encodePk(id), values); - return { collectionName: collection, recordId: ids, values: updatedRecord }; + return { collectionName: collection, recordId: id, values: updatedRecord }; } async getRelatedData({ collection, - ids, + id, relation, limit, fields, @@ -80,7 +80,7 @@ export default class AgentClientAgentPort implements AgentPort { const records = await this.client .collection(collection) - .relation(relation, encodePk(ids)) + .relation(relation, encodePk(id)) .list>({ ...(limit !== null && { pagination: { size: limit, number: 1 } }), ...(fields?.length && { fields }), @@ -96,14 +96,14 @@ export default class AgentClientAgentPort implements AgentPort { async executeAction({ collection, action, - ids, + id, }: { collection: string; action: string; - ids?: Id[]; + id?: Id[]; }): Promise { - const encodedIds = ids?.length ? [encodePk(ids)] : []; - const act = await this.client.collection(collection).action(action, { recordIds: encodedIds }); + const encodedId = id?.length ? [encodePk(id)] : []; + const act = await this.client.collection(collection).action(action, { recordIds: encodedId }); return act.execute(); } diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index b6bba6539c..95f5f69e08 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,11 +1,7 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; -import type { - LoadRelatedRecordStepExecutionData, - StepExecutionData, -} from '../types/step-execution-data'; -import type { BaseStepStatus, StepOutcome } from '../types/step-outcome'; +import type { BaseStepStatus } from '../types/step-outcome'; import type { AIMessage, BaseMessage } from '@langchain/core/messages'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -19,6 +15,7 @@ import { NoRecordsError, WorkflowExecutorError, } from '../errors'; +import StepSummaryBuilder from './step-summary-builder'; export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; @@ -75,54 +72,16 @@ export default abstract class BaseStepExecutor { if (!this.context.previousSteps.length) return []; - const summary = await this.summarizePreviousSteps(); - - return [new SystemMessage(summary)]; - } - - /** - * Builds a text summary of previously executed steps for AI prompts. - * Correlates history entries (step + stepOutcome pairs) with execution data - * from the RunStore (matched by stepOutcome.stepIndex). - * When no execution data is available, falls back to StepOutcome details. - */ - private async summarizePreviousSteps(): Promise { const allStepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - - return this.context.previousSteps + const summary = this.context.previousSteps .map(({ stepDefinition, stepOutcome }) => { const execution = allStepExecutions.find(e => e.stepIndex === stepOutcome.stepIndex); - return this.buildStepSummary(stepDefinition, stepOutcome, execution); + return StepSummaryBuilder.build(stepDefinition, stepOutcome, execution); }) .join('\n\n'); - } - private buildStepSummary( - step: StepDefinition, - stepOutcome: StepOutcome, - execution: StepExecutionData | undefined, - ): string { - const prompt = step.prompt ?? '(no prompt)'; - const header = `Step "${stepOutcome.stepId}" (index ${stepOutcome.stepIndex}):`; - const lines = [header, ` Prompt: ${prompt}`]; - - if (execution !== undefined) { - if (execution.executionParams !== undefined) { - lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); - } else if ('pendingData' in execution && execution.pendingData !== undefined) { - lines.push(` Pending: ${JSON.stringify(execution.pendingData)}`); - } - - if (execution.executionResult) { - lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); - } - } else { - const { stepId, stepIndex, type, ...historyDetails } = stepOutcome; - lines.push(` History: ${JSON.stringify(historyDetails)}`); - } - - return lines.join('\n'); + return [new SystemMessage(summary)]; } /** @@ -169,12 +128,17 @@ export default abstract class BaseStepExecutor { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const relatedRecords = stepExecutions - .filter( - (e): e is LoadRelatedRecordStepExecutionData & { record: RecordRef } => - e.type === 'load-related-record' && e.record !== undefined, - ) - .map(e => e.record); + const relatedRecords = stepExecutions.flatMap(e => { + if ( + e.type === 'load-related-record' && + e.executionResult !== undefined && + 'record' in e.executionResult + ) { + return [e.executionResult.record]; + } + + return []; + }); return [this.context.baseRecordRef, ...relatedRecords]; } 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 0055c873c1..284a3b3c08 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 @@ -163,7 +163,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto const relatedData = await this.context.agentPort.getRelatedData({ collection: selectedRecordRef.collectionName, - ids: selectedRecordRef.recordId, + id: selectedRecordRef.recordId, relation: name, limit, }); @@ -215,7 +215,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto const { selectedRecordRef, name } = target; const relatedData = await this.context.agentPort.getRelatedData({ collection: selectedRecordRef.collectionName, - ids: selectedRecordRef.recordId, + id: selectedRecordRef.recordId, relation: name, limit, }); @@ -241,9 +241,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto type: 'load-related-record', stepIndex: this.context.stepIndex, executionParams: { displayName, name }, - executionResult: { record }, + executionResult: { relation: { name, displayName }, record }, selectedRecordRef, - record, }); } catch (cause) { throw new StepPersistenceError( diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 36ab70756f..9d3834b2c6 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -36,7 +36,7 @@ export default class ReadRecordStepExecutor extends RecordTaskStepExecutor; - executeAction(query: { collection: string; action: string; ids?: Id[] }): Promise; + executeAction(query: { collection: string; action: string; id?: Id[] }): Promise; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 3fa13c8935..1143cea6c7 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -105,18 +105,16 @@ export interface LoadRelatedRecordPendingData extends RelationRef { export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { type: 'load-related-record'; - /** - * The record ref of the loaded related record. Absent during the pending phase. - * Also stored in executionResult.record for display consistency with other step types. - * This top-level field is used by getAvailableRecordRefs to build the record pool. - */ - record?: RecordRef; /** AI-selected relation with pre-fetched candidates awaiting user confirmation. */ pendingData?: LoadRelatedRecordPendingData; /** The record ref used to load the relation. Required for handleConfirmationFlow. */ selectedRecordRef: RecordRef; executionParams?: RelationRef; - executionResult?: { record: RecordRef } | { skipped: true }; + /** + * Navigation path captured at execution time — used by StepSummaryBuilder for AI context. + * Source is not repeated here — it is always selectedRecordRef, consistent with other step types. + */ + executionResult?: { relation: RelationRef; record: RecordRef } | { skipped: true }; } // -- Union -- diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index 38b8f4dd6e..cca7c3b4f9 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -77,7 +77,7 @@ describe('AgentClientAgentPort', () => { it('should return a RecordData for a simple PK', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - const result = await port.getRecord({ collection: 'users', ids: [42] }); + const result = await port.getRecord({ collection: 'users', id: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -93,7 +93,7 @@ describe('AgentClientAgentPort', () => { it('should build a composite And filter for composite PKs', async () => { mockCollection.list.mockResolvedValue([{ tenantId: 1, orderId: 2 }]); - await port.getRecord({ collection: 'orders', ids: [1, 2] }); + await port.getRecord({ collection: 'orders', id: [1, 2] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { @@ -110,7 +110,7 @@ describe('AgentClientAgentPort', () => { it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); - await expect(port.getRecord({ collection: 'users', ids: [999] })).rejects.toThrow( + await expect(port.getRecord({ collection: 'users', id: [999] })).rejects.toThrow( RecordNotFoundError, ); }); @@ -118,7 +118,7 @@ describe('AgentClientAgentPort', () => { it('should pass fields to list when fields is provided', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord({ collection: 'users', ids: [42], fields: ['id', 'name'] }); + await port.getRecord({ collection: 'users', id: [42], fields: ['id', 'name'] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -130,7 +130,7 @@ describe('AgentClientAgentPort', () => { it('should not pass fields to list when fields is an empty array', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord({ collection: 'users', ids: [42], fields: [] }); + await port.getRecord({ collection: 'users', id: [42], fields: [] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -141,7 +141,7 @@ describe('AgentClientAgentPort', () => { it('should not pass fields to list when fields is undefined', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord({ collection: 'users', ids: [42] }); + await port.getRecord({ collection: 'users', id: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -152,7 +152,7 @@ describe('AgentClientAgentPort', () => { it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRecord({ collection: 'unknown', ids: [1] }); + const result = await port.getRecord({ collection: 'unknown', id: [1] }); expect(mockCollection.list).toHaveBeenCalledWith( expect.objectContaining({ @@ -169,7 +169,7 @@ describe('AgentClientAgentPort', () => { const result = await port.updateRecord({ collection: 'users', - ids: [42], + id: [42], values: { name: 'Bob' }, }); @@ -184,7 +184,7 @@ describe('AgentClientAgentPort', () => { it('should encode composite PK to pipe format for update', async () => { mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); - await port.updateRecord({ collection: 'orders', ids: [1, 2], values: { status: 'done' } }); + await port.updateRecord({ collection: 'orders', id: [1, 2], values: { status: 'done' } }); expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); }); @@ -199,7 +199,7 @@ describe('AgentClientAgentPort', () => { const result = await port.getRelatedData({ collection: 'users', - ids: [42], + id: [42], relation: 'posts', limit: null, }); @@ -222,7 +222,7 @@ describe('AgentClientAgentPort', () => { it('should apply pagination when limit is a number', async () => { mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); - await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: 5 }); + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: 5 }); expect(mockRelation.list).toHaveBeenCalledWith( expect.objectContaining({ pagination: { size: 5, number: 1 } }), @@ -232,7 +232,7 @@ describe('AgentClientAgentPort', () => { it('should not apply pagination when limit is null', async () => { mockRelation.list.mockResolvedValue([]); - await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: null }); + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: null }); expect(mockRelation.list).toHaveBeenCalledWith({}); }); @@ -242,7 +242,7 @@ describe('AgentClientAgentPort', () => { const result = await port.getRelatedData({ collection: 'users', - ids: [42], + id: [42], relation: 'unknownRelation', limit: null, }); @@ -257,7 +257,7 @@ describe('AgentClientAgentPort', () => { expect( await port.getRelatedData({ collection: 'users', - ids: [42], + id: [42], relation: 'posts', limit: null, }), @@ -269,7 +269,7 @@ describe('AgentClientAgentPort', () => { await port.getRelatedData({ collection: 'users', - ids: [42], + id: [42], relation: 'posts', limit: null, fields: ['title'], @@ -283,7 +283,7 @@ describe('AgentClientAgentPort', () => { it('should omit fields from the list call when not provided', async () => { mockRelation.list.mockResolvedValue([{ id: 10 }]); - await port.getRelatedData({ collection: 'users', ids: [42], relation: 'posts', limit: null }); + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: null }); expect(mockRelation.list).toHaveBeenCalledWith( expect.not.objectContaining({ fields: expect.anything() }), @@ -298,7 +298,7 @@ describe('AgentClientAgentPort', () => { const result = await port.executeAction({ collection: 'users', action: 'sendEmail', - ids: [1], + id: [1], }); expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1'] }); @@ -318,7 +318,7 @@ describe('AgentClientAgentPort', () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); await expect( - port.executeAction({ collection: 'users', action: 'sendEmail', ids: [1] }), + port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }), ).rejects.toThrow('Action failed'); }); }); 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 cb6b64cbf4..abc78c2356 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -6,9 +6,11 @@ import type { RecordRef } from '../../src/types/record'; import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; import type { BaseStepStatus, StepOutcome } from '../../src/types/step-outcome'; -import type { BaseMessage, SystemMessage } from '@langchain/core/messages'; +import type { BaseMessage } from '@langchain/core/messages'; import type { DynamicStructuredTool } from '@langchain/core/tools'; +import { SystemMessage } from '@langchain/core/messages'; + import { MalformedToolCallError, MissingToolCallError, NoRecordsError } from '../../src/errors'; import BaseStepExecutor from '../../src/executors/base-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -115,7 +117,7 @@ describe('BaseStepExecutor', () => { expect(await executor.buildPreviousStepsMessages()).toEqual([]); }); - it('includes prompt and executionParams from previous steps', async () => { + it('calls getStepExecutions with runId and returns a SystemMessage with step content', async () => { const runStore = makeMockRunStore([ { type: 'condition', @@ -131,351 +133,46 @@ describe('BaseStepExecutor', () => { }), ); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + const messages = await executor.buildPreviousStepsMessages(); - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('Prompt: Approve?'); - expect(result).toContain('Input: {"answer":"Yes","reasoning":"Order is valid"}'); - expect(result).toContain('Output: {"answer":"Yes"}'); expect(runStore.getStepExecutions).toHaveBeenCalledWith('run-1'); + expect(messages).toHaveLength(1); + expect(messages[0]).toBeInstanceOf(SystemMessage); + expect(messages[0].content).toContain('Step "cond-1"'); + expect(messages[0].content).toContain('Prompt: Approve?'); }); - it('uses Input for matched steps and History for unmatched steps', async () => { - const executor = new TestableExecutor( - makeContext({ - previousSteps: [ - makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0 }), - makeHistoryEntry({ stepId: 'cond-2', stepIndex: 1, prompt: 'Second?' }), - ], - // Only step 1 has an execution entry — step 0 has no match - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 1, - executionParams: { answer: 'No', reasoning: 'Clearly no' }, - executionResult: { answer: 'No' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('History: {"status":"success"}'); - expect(result).toContain('Step "cond-2"'); - expect(result).toContain('Input: {"answer":"No","reasoning":"Clearly no"}'); - expect(result).toContain('Output: {"answer":"No"}'); - }); - - it('falls back to History when no matching step execution in RunStore', async () => { - const executor = new TestableExecutor( - makeContext({ - previousSteps: [ - makeHistoryEntry({ stepId: 'orphan', stepIndex: 5, prompt: 'Orphan step' }), - makeHistoryEntry({ stepId: 'matched', stepIndex: 1, prompt: 'Matched step' }), - ], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 1, - executionParams: { answer: 'B', reasoning: 'Option B fits' }, - executionResult: { answer: 'B' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "orphan"'); - expect(result).toContain('History: {"status":"success"}'); - expect(result).toContain('Step "matched"'); - expect(result).toContain('Input: {"answer":"B","reasoning":"Option B fits"}'); - expect(result).toContain('Output: {"answer":"B"}'); - }); - - it('includes selectedOption in History for condition steps', async () => { - const entry = makeHistoryEntry({ - stepId: 'cond-approval', - stepIndex: 0, - prompt: 'Approved?', - }); - (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "cond-approval"'); - expect(result).toContain('"selectedOption":"Yes"'); - }); - - it('includes error in History for failed steps', async () => { - const entry = makeHistoryEntry({ - stepId: 'failing-step', - stepIndex: 0, - prompt: 'Do something', - }); - entry.stepOutcome.status = 'error'; - (entry.stepOutcome as { error?: string }).error = 'AI could not match an option'; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('"status":"error"'); - expect(result).toContain('"error":"AI could not match an option"'); - }); - - it('includes status in History for record-task steps without RunStore data', async () => { - const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Run task', - }, - stepOutcome: { - type: 'record-task', - stepId: 'read-record-1', + it('separates multiple previous steps with a blank line', async () => { + const runStore = makeMockRunStore([ + { + type: 'condition', stepIndex: 0, - status: 'awaiting-input', - }, - }; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "read-record-1"'); - expect(result).toContain('History: {"status":"awaiting-input"}'); - }); - - it('uses Input when RunStore has executionParams, History otherwise', async () => { - const condEntry = makeHistoryEntry({ - stepId: 'cond-1', - stepIndex: 0, - prompt: 'Approved?', - }); - (condEntry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; - - const aiEntry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Read name', + executionParams: { answer: 'Yes', reasoning: 'Valid' }, + executionResult: { answer: 'Yes' }, }, - stepOutcome: { - type: 'record-task', - stepId: 'read-customer', + { + type: 'condition', stepIndex: 1, - status: 'success', + executionParams: { answer: 'No', reasoning: 'Wrong' }, + executionResult: { answer: 'No' }, }, - }; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [condEntry, aiEntry], - runStore: makeMockRunStore([ - { - type: 'record-task', - stepIndex: 1, - executionParams: { answer: 'John Doe' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('History: {"status":"success","selectedOption":"Yes"}'); - expect(result).toContain('Step "read-customer"'); - expect(result).toContain('Input: {"answer":"John Doe"}'); - }); - - it('prefers RunStore execution data over History fallback', async () => { - const entry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Pick one' }); - (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'A'; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 0, - executionParams: { answer: 'A', reasoning: 'Best fit' }, - executionResult: { answer: 'A' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Input: {"answer":"A","reasoning":"Best fit"}'); - expect(result).toContain('Output: {"answer":"A"}'); - expect(result).not.toContain('History:'); - }); - - it('omits Input line when executionParams is undefined', async () => { - const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Do something', - }, - stepOutcome: { - type: 'record-task', - stepId: 'read-record-1', - stepIndex: 0, - status: 'success', - }, - }; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([ - { - type: 'record-task', - stepIndex: 0, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "read-record-1"'); - expect(result).toContain('Prompt: Do something'); - expect(result).not.toContain('Input:'); - }); - - it('uses Pending when update-record step has pendingUpdate but no executionParams', async () => { - const executor = new TestableExecutor( - makeContext({ - previousSteps: [ - { - stepDefinition: { type: StepType.UpdateRecord, prompt: 'Set status to active' }, - stepOutcome: { - type: 'record-task', - stepId: 'update-1', - stepIndex: 0, - status: 'awaiting-input', - }, - }, - ], - runStore: makeMockRunStore([ - { - type: 'update-record', - stepIndex: 0, - pendingData: { displayName: 'Status', name: 'status', value: 'active' }, - selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Pending:'); - expect(result).toContain('"displayName":"Status"'); - expect(result).toContain('"value":"active"'); - expect(result).not.toContain('Input:'); - }); - - it('includes pending action in summary for trigger-action step', async () => { + ]); const executor = new TestableExecutor( makeContext({ previousSteps: [ - { - stepDefinition: { type: StepType.TriggerAction, prompt: 'Archive the customer' }, - stepOutcome: { - type: 'record-task', - stepId: 'trigger-1', - stepIndex: 0, - status: 'awaiting-input', - }, - }, + makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'First?' }), + makeHistoryEntry({ stepId: 'cond-2', stepIndex: 1, prompt: 'Second?' }), ], - runStore: makeMockRunStore([ - { - type: 'trigger-action', - stepIndex: 0, - pendingData: { displayName: 'Archive Customer', name: 'archive' }, - selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Pending:'); - expect(result).toContain('"displayName":"Archive Customer"'); - expect(result).toContain('"name":"archive"'); - expect(result).not.toContain('Input:'); - }); - - it('shows "(no prompt)" when step has no prompt', async () => { - const entry = makeHistoryEntry({ stepIndex: 0 }); - entry.stepDefinition.prompt = undefined; - - const executor = new TestableExecutor( - makeContext({ - previousSteps: [entry], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 0, - executionParams: { answer: 'A', reasoning: 'Only option' }, - executionResult: { answer: 'A' }, - }, - ]), + runStore, }), ); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + const messages = await executor.buildPreviousStepsMessages(); + const content = messages[0].content as string; - expect(result).toContain('Prompt: (no prompt)'); + expect(content).toContain('Step "cond-1"'); + expect(content).toContain('Step "cond-2"'); + expect(content).toContain('\n\nStep "cond-2"'); }); }); 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 1935cf35b0..d1d01fbf4c 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 @@ -160,7 +160,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.getRelatedData).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], relation: 'order', limit: 1, }); @@ -170,18 +170,17 @@ describe('LoadRelatedRecordStepExecutor', () => { type: 'load-related-record', stepIndex: 0, executionParams: { displayName: 'Order', name: 'order' }, - executionResult: { + executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'orders', recordId: [99], stepIndex: 0, }), - }, + }), selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], }), - record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), }), ); }); @@ -268,7 +267,7 @@ describe('LoadRelatedRecordStepExecutor', () => { // Fetches 50 candidates (HasMany) expect(agentPort.getRelatedData).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], relation: 'address', limit: 50, }); @@ -276,9 +275,9 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionResult: { + executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'addresses', recordId: [2] }), - }, + }), }), ); }); @@ -547,16 +546,16 @@ describe('LoadRelatedRecordStepExecutor', () => { // HasOne uses the same fetchFirstCandidate path as BelongsTo — limit: 1 expect(agentPort.getRelatedData).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], relation: 'profile', limit: 1, }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionResult: { + executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'profiles', recordId: [5] }), - }, + }), }), ); }); @@ -575,7 +574,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('awaiting-input'); expect(agentPort.getRelatedData).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], relation: 'order', limit: 50, }); @@ -754,9 +753,9 @@ describe('LoadRelatedRecordStepExecutor', () => { expect.objectContaining({ type: 'load-related-record', executionParams: { displayName: 'Order', name: 'order' }, - executionResult: { + executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), - }, + }), pendingData: expect.objectContaining({ displayName: 'Order', name: 'order', @@ -792,9 +791,9 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionResult: { + executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), - }, + }), }), ); }); @@ -1166,7 +1165,10 @@ describe('LoadRelatedRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), @@ -1352,7 +1354,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.getRelatedData).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], relation: 'order', limit: 1, }); @@ -1439,7 +1441,10 @@ describe('LoadRelatedRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: completedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: completedRecord, + }, selectedRecordRef: makeRecordRef(), }, pendingExecution, 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 1d4145f4a2..7a9c6ec9d9 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 @@ -207,7 +207,7 @@ describe('ReadRecordStepExecutor', () => { expect(agentPort.getRecord).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], fields: ['name', 'email'], }); }); @@ -223,7 +223,7 @@ describe('ReadRecordStepExecutor', () => { expect(agentPort.getRecord).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], fields: ['email'], }); }); @@ -374,7 +374,10 @@ describe('ReadRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), @@ -461,7 +464,10 @@ describe('ReadRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), @@ -535,7 +541,10 @@ describe('ReadRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 5, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), @@ -588,7 +597,10 @@ describe('ReadRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 1, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts new file mode 100644 index 0000000000..b6765997c9 --- /dev/null +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -0,0 +1,91 @@ +import type { StepExecutionData } from '../../src/types/step-execution-data'; + +import StepExecutionFormatters from '../../src/executors/step-execution-formatters'; + +describe('StepExecutionFormatters', () => { + describe('format', () => { + describe('load-related-record', () => { + it('returns the full Loaded: line for a completed execution', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { + relation: { name: 'address', displayName: 'Address' }, + record: { collectionName: 'addresses', recordId: [1], stepIndex: 1 }, + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Loaded: customers #42 → [Address] → addresses #1 (step 1)', + ); + }); + + it('returns null for a skipped execution', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { skipped: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null when executionResult is absent (pending phase)', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + pendingData: { + displayName: 'Address', + name: 'address', + relatedCollectionName: 'addresses', + suggestedRecordId: [1], + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('formats composite record IDs joined by ", "', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42, 'abc'], stepIndex: 0 }, + executionResult: { + relation: { name: 'orders', displayName: 'Orders' }, + record: { collectionName: 'orders', recordId: [1, 'xyz'], stepIndex: 1 }, + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Loaded: customers #42, abc → [Orders] → orders #1, xyz (step 1)', + ); + }); + }); + + describe('types without a custom formatter', () => { + it('returns null for condition type', () => { + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes' }, + executionResult: { answer: 'Yes' }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null for record-task type', () => { + const execution: StepExecutionData = { + type: 'record-task', + stepIndex: 0, + executionResult: { success: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts new file mode 100644 index 0000000000..6af99f0cf7 --- /dev/null +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -0,0 +1,284 @@ +import type { StepDefinition } from '../../src/types/step-definition'; +import type { StepExecutionData } from '../../src/types/step-execution-data'; +import type { StepOutcome } from '../../src/types/step-outcome'; + +import StepSummaryBuilder from '../../src/executors/step-summary-builder'; +import { StepType } from '../../src/types/step-definition'; + +function makeConditionStep(prompt?: string): StepDefinition { + return { type: StepType.Condition, options: ['A', 'B'], prompt }; +} + +function makeConditionOutcome( + stepId: string, + stepIndex: number, + extra: Record = {}, +): StepOutcome { + return { type: 'condition', stepId, stepIndex, status: 'success', ...extra } as StepOutcome; +} + +describe('StepSummaryBuilder', () => { + describe('build', () => { + it('renders header, prompt, Input, and Output for a condition step with execution data', () => { + const step = makeConditionStep('Approve?'); + const outcome = makeConditionOutcome('cond-1', 0); + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Order is valid' }, + executionResult: { answer: 'Yes' }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Step "cond-1" (index 0):'); + expect(result).toContain('Prompt: Approve?'); + expect(result).toContain('Input: {"answer":"Yes","reasoning":"Order is valid"}'); + expect(result).toContain('Output: {"answer":"Yes"}'); + }); + + it('renders Output: when executionResult is present but executionParams is absent', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Do something' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'task-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'record-task', + stepIndex: 0, + executionResult: { success: true }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Output: {"success":true}'); + expect(result).not.toContain('Input:'); + }); + + it('falls back to History when no execution data is provided', () => { + const step = makeConditionStep('Pick one'); + const outcome = makeConditionOutcome('cond-1', 0); + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('Step "cond-1" (index 0):'); + expect(result).toContain('Prompt: Pick one'); + expect(result).toContain('History: {"status":"success"}'); + expect(result).not.toContain('"stepId"'); + expect(result).not.toContain('"stepIndex"'); + expect(result).not.toContain('"type"'); + }); + + it('includes selectedOption in History for condition steps', () => { + const step = makeConditionStep('Approved?'); + const outcome = makeConditionOutcome('cond-approval', 0, { selectedOption: 'Yes' }); + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('"selectedOption":"Yes"'); + }); + + it('includes error in History for failed steps', () => { + const step = makeConditionStep('Do something'); + const outcome: StepOutcome = { + type: 'condition', + stepId: 'failing-step', + stepIndex: 0, + status: 'error', + error: 'AI could not match an option', + }; + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('"status":"error"'); + expect(result).toContain('"error":"AI could not match an option"'); + }); + + it('omits History type field and includes status for record-task steps', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Run task' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'read-record-1', + stepIndex: 0, + status: 'awaiting-input', + }; + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('Step "read-record-1" (index 0):'); + expect(result).toContain('History: {"status":"awaiting-input"}'); + }); + + it('omits Input and Output lines when executionParams and executionResult are both absent', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Do something' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'read-record-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { type: 'record-task', stepIndex: 0 }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Step "read-record-1" (index 0):'); + expect(result).toContain('Prompt: Do something'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + }); + + it('uses Pending when update-record step has pendingData but no executionParams', () => { + const step: StepDefinition = { type: StepType.UpdateRecord, prompt: 'Set status to active' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Status"'); + expect(result).toContain('"value":"active"'); + expect(result).not.toContain('Input:'); + }); + + it('uses Pending for trigger-action step with pendingData', () => { + const step: StepDefinition = { + type: StepType.TriggerAction, + prompt: 'Archive the customer', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { displayName: 'Archive Customer', name: 'archive' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Archive Customer"'); + expect(result).toContain('"name":"archive"'); + expect(result).not.toContain('Input:'); + }); + + it('renders load-related-record completed as Loaded: (no Input: or Output:)', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { + relation: { name: 'address', displayName: 'Address' }, + record: { collectionName: 'addresses', recordId: [1], stepIndex: 1 }, + }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + const lines = result.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('Step "load-1" (index 1):'); + expect(lines[1]).toBe(' Prompt: Load the address'); + expect(lines[2]).toBe(' Loaded: customers #42 → [Address] → addresses #1 (step 1)'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + }); + + it('renders load-related-record skipped as generic Output: fallback', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { skipped: true }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Output: {"skipped":true}'); + expect(result).not.toContain('Loaded:'); + }); + + it('renders load-related-record pending state with Pending: line', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + pendingData: { + displayName: 'Address', + name: 'address', + relatedCollectionName: 'addresses', + suggestedRecordId: [1], + }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Address"'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + expect(result).not.toContain('Loaded:'); + }); + + it('shows "(no prompt)" when step has no prompt', () => { + const step: StepDefinition = { type: StepType.Condition, options: ['A', 'B'] }; + const outcome = makeConditionOutcome('cond-1', 0); + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'A', reasoning: 'Only option' }, + executionResult: { answer: 'A' }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Prompt: (no prompt)'); + }); + }); +}); 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 0efef7002a..85ae764b31 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 @@ -136,7 +136,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(agentPort.executeAction).toHaveBeenCalledWith({ collection: 'customers', action: 'send-welcome-email', - ids: [42], + id: [42], }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -216,7 +216,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(agentPort.executeAction).toHaveBeenCalledWith({ collection: 'customers', action: 'send-welcome-email', - ids: [42], + id: [42], }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -530,7 +530,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(agentPort.executeAction).toHaveBeenCalledWith({ collection: 'customers', action: 'archive', - ids: [42], + id: [42], }); }); @@ -559,7 +559,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(agentPort.executeAction).toHaveBeenCalledWith({ collection: 'customers', action: 'archive', - ids: [42], + id: [42], }); }); }); @@ -608,7 +608,10 @@ describe('TriggerRecordActionStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), 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 2f9da2768e..2b2bf7fcec 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 @@ -143,7 +143,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], values: { status: 'active' }, }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -216,7 +216,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], values: { status: 'active' }, }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -383,7 +383,10 @@ describe('UpdateRecordStepExecutor', () => { { type: 'load-related-record', stepIndex: 2, - record: relatedRecord, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, selectedRecordRef: makeRecordRef(), }, ]), @@ -686,7 +689,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith({ collection: 'customers', - ids: [42], + id: [42], values: { status: 'active' }, }); }); From c19db334a9dd924302620ea15cb3b52f54bad1bb Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 21:01:44 +0100 Subject: [PATCH 15/34] refactor(workflow-executor): named query types, save actionResult, move summary to sub-folder - Replace QueryBase intersections with named query types (GetRecordQuery, UpdateRecordQuery, GetRelatedDataQuery, ExecuteActionQuery) for better DX and readability - Save executeAction return value in executionResult.actionResult instead of discarding it - Move StepSummaryBuilder and StepExecutionFormatters to executors/summary/ sub-folder Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/agent-client-agent-port.ts | 25 +++++++++---------- .../src/executors/base-step-executor.ts | 2 +- .../step-execution-formatters.ts | 2 +- .../{ => summary}/step-summary-builder.ts | 6 ++--- .../trigger-record-action-step-executor.ts | 6 ++--- packages/workflow-executor/src/index.ts | 10 +++++++- .../workflow-executor/src/ports/agent-port.ts | 24 ++++++++++-------- .../src/types/step-execution-data.ts | 2 +- .../step-execution-formatters.test.ts | 2 +- .../executors/step-summary-builder.test.ts | 2 +- ...rigger-record-action-step-executor.test.ts | 6 +++-- 11 files changed, 49 insertions(+), 38 deletions(-) rename packages/workflow-executor/src/executors/{ => summary}/step-execution-formatters.ts (97%) rename packages/workflow-executor/src/executors/{ => summary}/step-summary-builder.ts (87%) diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 06017640fb..fb146b3472 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,4 +1,11 @@ -import type { AgentPort, Id, Limit, QueryBase } from '../ports/agent-port'; +import type { + AgentPort, + ExecuteActionQuery, + GetRecordQuery, + GetRelatedDataQuery, + Id, + UpdateRecordQuery, +} from '../ports/agent-port'; import type { CollectionSchema } from '../types/record'; import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; @@ -46,7 +53,7 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionSchemas = params.collectionSchemas; } - async getRecord({ collection, id, fields }: QueryBase) { + async getRecord({ collection, id, fields }: GetRecordQuery) { const schema = this.resolveSchema(collection); const records = await this.client.collection(collection).list>({ filters: buildPkFilter(schema.primaryKeyFields, id), @@ -61,7 +68,7 @@ export default class AgentClientAgentPort implements AgentPort { return { collectionName: collection, recordId: id, values: records[0] }; } - async updateRecord({ collection, id, values }: QueryBase & { values: Record }) { + async updateRecord({ collection, id, values }: UpdateRecordQuery) { const updatedRecord = await this.client .collection(collection) .update>(encodePk(id), values); @@ -75,7 +82,7 @@ export default class AgentClientAgentPort implements AgentPort { relation, limit, fields, - }: QueryBase & { relation: string } & Limit) { + }: GetRelatedDataQuery) { const relatedSchema = this.resolveSchema(relation); const records = await this.client @@ -93,15 +100,7 @@ export default class AgentClientAgentPort implements AgentPort { })); } - async executeAction({ - collection, - action, - id, - }: { - collection: string; - action: string; - id?: Id[]; - }): Promise { + async executeAction({ collection, action, id }: ExecuteActionQuery): Promise { const encodedId = id?.length ? [encodePk(id)] : []; const act = await this.client.collection(collection).action(action, { recordIds: encodedId }); diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 95f5f69e08..6b191038ce 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -15,7 +15,7 @@ import { NoRecordsError, WorkflowExecutorError, } from '../errors'; -import StepSummaryBuilder from './step-summary-builder'; +import StepSummaryBuilder from './summary/step-summary-builder'; export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; diff --git a/packages/workflow-executor/src/executors/step-execution-formatters.ts b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts similarity index 97% rename from packages/workflow-executor/src/executors/step-execution-formatters.ts rename to packages/workflow-executor/src/executors/summary/step-execution-formatters.ts index 8c87199e2a..8a86035528 100644 --- a/packages/workflow-executor/src/executors/step-execution-formatters.ts +++ b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts @@ -1,7 +1,7 @@ import type { LoadRelatedRecordStepExecutionData, StepExecutionData, -} from '../types/step-execution-data'; +} from '../../types/step-execution-data'; /** * Stateless utility class — all methods are static. diff --git a/packages/workflow-executor/src/executors/step-summary-builder.ts b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts similarity index 87% rename from packages/workflow-executor/src/executors/step-summary-builder.ts rename to packages/workflow-executor/src/executors/summary/step-summary-builder.ts index 39709f68c0..abe155924f 100644 --- a/packages/workflow-executor/src/executors/step-summary-builder.ts +++ b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts @@ -1,6 +1,6 @@ -import type { StepDefinition } from '../types/step-definition'; -import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepOutcome } from '../types/step-outcome'; +import type { StepDefinition } from '../../types/step-definition'; +import type { StepExecutionData } from '../../types/step-execution-data'; +import type { StepOutcome } from '../../types/step-outcome'; import StepExecutionFormatters from './step-execution-formatters'; 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 0dd5483215..cd2f81925d 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 @@ -85,9 +85,7 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu ): Promise { const { selectedRecordRef, displayName, name } = target; - // Return value intentionally discarded: action results may contain client data - // and must not leave the client's infrastructure (privacy constraint). - await this.context.agentPort.executeAction({ + const actionResult = await this.context.agentPort.executeAction({ collection: selectedRecordRef.collectionName, action: name, id: selectedRecordRef.recordId, @@ -99,7 +97,7 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu type: 'trigger-action', stepIndex: this.context.stepIndex, executionParams: { displayName, name }, - executionResult: { success: true }, + executionResult: { success: true, actionResult }, selectedRecordRef, }); } catch (cause) { diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 8a3bc60952..565063bd99 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -45,7 +45,15 @@ export type { ExecutionContext, } from './types/execution'; -export type { AgentPort, Id, QueryBase, Limit } from './ports/agent-port'; +export type { + AgentPort, + ExecuteActionQuery, + GetRecordQuery, + GetRelatedDataQuery, + Id, + Limit, + UpdateRecordQuery, +} from './ports/agent-port'; export type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; export type { RunStore } from './ports/run-store'; export type { Logger } from './ports/logger-port'; diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 39e630921b..4a95c92cdf 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -4,20 +4,24 @@ import type { RecordData } from '../types/record'; export type Id = string | number; -export type QueryBase = { +export type Limit = { limit: number } | { limit: null }; + +export type GetRecordQuery = { collection: string; id: Id[]; fields?: string[] }; + +export type UpdateRecordQuery = { collection: string; id: Id[]; values: Record }; + +export type GetRelatedDataQuery = { collection: string; id: Id[]; + relation: string; fields?: string[]; -}; +} & Limit; -export type Limit = { limit: number } | { limit: null }; +export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] }; export interface AgentPort { - getRecord(query: QueryBase): Promise; - - updateRecord(query: QueryBase & { values: Record }): Promise; - - getRelatedData(query: QueryBase & { relation: string } & Limit): Promise; - - executeAction(query: { collection: string; action: string; id?: Id[] }): Promise; + getRecord(query: GetRecordQuery): Promise; + updateRecord(query: UpdateRecordQuery): Promise; + getRelatedData(query: GetRelatedDataQuery): Promise; + executeAction(query: ExecuteActionQuery): Promise; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 1143cea6c7..f76fa6bead 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -72,7 +72,7 @@ export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionD type: 'trigger-action'; /** Display name and technical name of the executed action. */ executionParams?: ActionRef; - executionResult?: { success: true } | { skipped: true }; + executionResult?: { success: true; actionResult: unknown } | { skipped: true }; /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ pendingData?: ActionRef; selectedRecordRef: RecordRef; diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts index b6765997c9..82f3f76fc2 100644 --- a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -1,6 +1,6 @@ import type { StepExecutionData } from '../../src/types/step-execution-data'; -import StepExecutionFormatters from '../../src/executors/step-execution-formatters'; +import StepExecutionFormatters from '../../src/executors/summary/step-execution-formatters'; describe('StepExecutionFormatters', () => { describe('format', () => { diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts index 6af99f0cf7..27b3308365 100644 --- a/packages/workflow-executor/test/executors/step-summary-builder.test.ts +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -2,7 +2,7 @@ import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; import type { StepOutcome } from '../../src/types/step-outcome'; -import StepSummaryBuilder from '../../src/executors/step-summary-builder'; +import StepSummaryBuilder from '../../src/executors/summary/step-summary-builder'; import { StepType } from '../../src/types/step-definition'; function makeConditionStep(prompt?: string): StepDefinition { 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 85ae764b31..61a313a213 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 @@ -117,6 +117,7 @@ describe('TriggerRecordActionStepExecutor', () => { describe('automaticExecution: trigger direct (Branch B)', () => { it('triggers the action and returns success', async () => { const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'User requested welcome email', @@ -147,7 +148,7 @@ describe('TriggerRecordActionStepExecutor', () => { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, - executionResult: { success: true }, + executionResult: { success: true, actionResult: { message: 'Email sent' } }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -194,6 +195,7 @@ describe('TriggerRecordActionStepExecutor', () => { describe('confirmation accepted (Branch A)', () => { it('triggers the action when user confirms and preserves pendingAction', async () => { const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); const execution: TriggerRecordActionStepExecutionData = { type: 'trigger-action', stepIndex: 0, @@ -226,7 +228,7 @@ describe('TriggerRecordActionStepExecutor', () => { displayName: 'Send Welcome Email', name: 'send-welcome-email', }, - executionResult: { success: true }, + executionResult: { success: true, actionResult: { message: 'Email sent' } }, pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email', From 971cd67714e1fc1bbf93f3b73a82187bfacd9f28 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 21:09:49 +0100 Subject: [PATCH 16/34] style(workflow-executor): remove unused Id import and inline getRelatedData signature Co-Authored-By: Claude Sonnet 4.6 --- .../src/adapters/agent-client-agent-port.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index fb146b3472..963e7de4be 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -3,7 +3,6 @@ import type { ExecuteActionQuery, GetRecordQuery, GetRelatedDataQuery, - Id, UpdateRecordQuery, } from '../ports/agent-port'; import type { CollectionSchema } from '../types/record'; @@ -76,13 +75,7 @@ export default class AgentClientAgentPort implements AgentPort { return { collectionName: collection, recordId: id, values: updatedRecord }; } - async getRelatedData({ - collection, - id, - relation, - limit, - fields, - }: GetRelatedDataQuery) { + async getRelatedData({ collection, id, relation, limit, fields }: GetRelatedDataQuery) { const relatedSchema = this.resolveSchema(relation); const records = await this.client From 91f240b106f4c01c4ab31ceb3179911e0a255b6b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 21 Mar 2026 23:40:32 +0100 Subject: [PATCH 17/34] refactor(workflow-executor): move remoteTools out of ExecutionContext into McpTaskStepExecutor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remoteTools was a dependency of McpTaskStepExecutor only — 5 of 6 executors never used it. Inject it explicitly via the constructor so ExecutionContext stays focused on execution state, and remove the now-meaningless remoteTools: [] boilerplate from every other executor test. Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/mcp-task-step-executor.ts | 136 ++++ .../workflow-executor/src/types/execution.ts | 1 - .../test/executors/base-step-executor.test.ts | 1 - .../executors/condition-step-executor.test.ts | 1 - .../load-related-record-step-executor.test.ts | 1 - .../executors/mcp-task-step-executor.test.ts | 603 ++++++++++++++++++ .../read-record-step-executor.test.ts | 1 - ...rigger-record-action-step-executor.test.ts | 1 - .../update-record-step-executor.test.ts | 1 - 9 files changed, 739 insertions(+), 7 deletions(-) create mode 100644 packages/workflow-executor/src/executors/mcp-task-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts new file mode 100644 index 0000000000..3a4ae4692a --- /dev/null +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -0,0 +1,136 @@ +import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { McpTaskStepDefinition } from '../types/step-definition'; +import type { McpTaskStepExecutionData, McpToolCall } from '../types/step-execution-data'; +import type { RemoteTool } from '@forestadmin/ai-proxy'; + +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; + +import { + McpToolInvocationError, + McpToolNotFoundError, + NoMcpToolsError, + StepPersistenceError, +} from '../errors'; +import RecordTaskStepExecutor from './record-task-step-executor'; + +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. + +Important rules: +- Select only the tool directly relevant to the request. +- Final answer is definitive, you won't receive any other input from the user.`; + +export default class McpTaskStepExecutor extends RecordTaskStepExecutor { + private readonly remoteTools: readonly RemoteTool[]; + + constructor( + context: ExecutionContext, + remoteTools: readonly RemoteTool[], + ) { + super(context); + this.remoteTools = remoteTools; + } + + protected async doExecute(): Promise { + if (this.context.userConfirmed !== undefined) { + // Branch A -- Re-entry with user confirmation + return this.handleConfirmationFlow('mcp-task', execution => + this.executeToolAndPersist(execution.pendingData as McpToolCall, execution), + ); + } + + // Branches B & C -- First call + const tools = this.getFilteredTools(); + const { toolName, args } = await this.selectTool(tools); + const target: McpToolCall = { name: toolName, input: args }; + + if (this.context.stepDefinition.automaticExecution) { + // Branch B -- direct execution + return this.executeToolAndPersist(target); + } + + // Branch C -- Awaiting confirmation + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'mcp-task', + stepIndex: this.context.stepIndex, + pendingData: target, + }); + } catch (cause) { + throw new StepPersistenceError( + `MCP task step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + private async executeToolAndPersist( + target: McpToolCall, + existingExecution?: McpTaskStepExecutionData, + ): Promise { + const tools = this.getFilteredTools(); + const tool = tools.find(t => t.base.name === target.name); + if (!tool) throw new McpToolNotFoundError(target.name); + + let toolResult: unknown; + + try { + toolResult = await tool.base.invoke(target.input); + } catch (cause) { + this.context.logger.error('MCP tool invocation failed', { + runId: this.context.runId, + stepIndex: this.context.stepIndex, + toolName: target.name, + error: cause instanceof Error ? cause.message : String(cause), + }); + throw new McpToolInvocationError(target.name, cause); + } + + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'mcp-task', + stepIndex: this.context.stepIndex, + executionParams: { name: target.name, input: target.input }, + executionResult: { success: true, toolResult }, + }); + } catch (cause) { + throw new StepPersistenceError( + `MCP tool "${target.name}" executed but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async selectTool(tools: RemoteTool[]) { + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(MCP_TASK_SYSTEM_PROMPT), + new HumanMessage( + `**Request**: ${this.context.stepDefinition.prompt ?? 'Execute the relevant tool.'}`, + ), + ]; + + return this.invokeWithTools( + messages, + tools.map(t => t.base), + ); + } + + /** Returns tools filtered by mcpServerId (if specified). Throws NoMcpToolsError if empty. */ + private getFilteredTools(): RemoteTool[] { + const { mcpServerId } = this.context.stepDefinition; + const tools = mcpServerId + ? this.remoteTools.filter(t => t.sourceId === mcpServerId) + : [...this.remoteTools]; + if (tools.length === 0) throw new NoMcpToolsError(); + + return tools; + } +} diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index efd759ea5c..59c4ed8420 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -39,7 +39,6 @@ export interface ExecutionContext readonly workflowPort: WorkflowPort; readonly runStore: RunStore; readonly previousSteps: ReadonlyArray>; - readonly remoteTools: readonly unknown[]; readonly userConfirmed?: boolean; readonly logger: Logger; } 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 abc78c2356..9dee1bc683 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -103,7 +103,6 @@ function makeContext(overrides: Partial = {}): ExecutionContex workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: makeMockLogger(), ...overrides, }; 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 3f1bbe5402..696b200ab4 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -54,7 +54,6 @@ function makeContext( workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: { error: jest.fn() }, ...overrides, }; 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 d1d01fbf4c..330a72a888 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 @@ -116,7 +116,6 @@ function makeContext( workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: { error: jest.fn() }, ...overrides, }; diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts new file mode 100644 index 0000000000..ddabbb9ddc --- /dev/null +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -0,0 +1,603 @@ +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { McpTaskStepDefinition } from '../../src/types/step-definition'; +import type { McpTaskStepExecutionData } from '../../src/types/step-execution-data'; + +import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; + +import { StepStateError } from '../../src/errors'; +import McpTaskStepExecutor from '../../src/executors/mcp-task-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +class MockRemoteTool extends RemoteTool { + constructor(options: { name: string; sourceId?: string; invoke?: jest.Mock }) { + const invokeFn = options.invoke ?? jest.fn().mockResolvedValue('tool-result'); + super({ + tool: { + name: options.name, + description: `${options.name} description`, + schema: { parse: jest.fn(), _def: {} } as unknown as RemoteTool['base']['schema'], + invoke: invokeFn, + } as unknown as RemoteTool['base'], + sourceId: options.sourceId ?? 'mcp-server-1', + sourceType: 'mcp', + }); + } +} + +function makeStep(overrides: Partial = {}): McpTaskStepDefinition { + return { + type: StepType.McpTask, + prompt: 'Send a notification to the user', + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort(): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest.fn().mockResolvedValue({ + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [], + actions: [], + }), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolName: string, toolArgs: Record) { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [{ name: toolName, args: toolArgs, id: 'call_1' }], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'mcp-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + stepDefinition: makeStep(), + model: makeMockModel('send_notification', { message: 'Hello' }).model, + agentPort: { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn(), + executeAction: jest.fn(), + } as unknown as ExecutionContext['agentPort'], + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + logger: { error: jest.fn() }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('McpTaskStepExecutor', () => { + describe('automaticExecution: direct execution (Branch B)', () => { + it('invokes the tool and returns success', async () => { + const invokeFn = jest.fn().mockResolvedValue({ result: 'notification sent' }); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { + message: 'Hello', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).toHaveBeenCalledWith({ message: 'Hello' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + stepIndex: 0, + executionParams: { name: 'send_notification', input: { message: 'Hello' } }, + executionResult: { success: true, toolResult: { result: 'notification sent' } }, + }), + ); + // Verify the model was bound with the tool's base interface + expect(modelInvoke).toHaveBeenCalledTimes(1); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves pendingData and returns awaiting-input', async () => { + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const runStore = makeMockRunStore(); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ model, runStore }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + + it('returns error when saveStepExecution fails (Branch C)', async () => { + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('DB unavailable')), + }); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ model, runStore, logger }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'Step side effect succeeded but persistence failed', + expect.objectContaining({ cause: 'DB unavailable' }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('loads pendingData, invokes the tool, and persists the result', async () => { + const invokeFn = jest.fn().mockResolvedValue('email sent'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).toHaveBeenCalledWith({ message: 'Hello' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + executionParams: { name: 'send_notification', input: { message: 'Hello' } }, + executionResult: { success: true, toolResult: 'email sent' }, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('saves skipped result and returns success without invoking the tool', async () => { + const invokeFn = jest.fn(); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: false }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + }); + + describe('mcpServerId filter', () => { + it('passes only tools from the specified server to the AI', async () => { + const toolA = new MockRemoteTool({ name: 'tool_a', sourceId: 'server-A' }); + const toolB = new MockRemoteTool({ name: 'tool_b', sourceId: 'server-B' }); + const invokeFn = jest.fn().mockResolvedValue('ok'); + const toolB2 = new MockRemoteTool({ + name: 'tool_b2', + sourceId: 'server-B', + invoke: invokeFn, + }); + + const { model, bindTools } = makeMockModel('tool_b', {}); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ mcpServerId: 'server-B', automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [toolA, toolB, toolB2]); + + await executor.execute(); + + // bindTools should only receive server-B tools + const boundTools = bindTools.mock.calls[0][0] as Array<{ name: string }>; + const boundNames = boundTools.map(t => t.name); + expect(boundNames).not.toContain('tool_a'); + expect(boundNames).toContain('tool_b'); + expect(boundNames).toContain('tool_b2'); + }); + }); + + describe('NoMcpToolsError', () => { + it('returns error when remoteTools is empty', async () => { + const context = makeContext(); + const executor = new McpTaskStepExecutor(context, []); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); + }); + + it('returns error when mcpServerId filter yields no tools', async () => { + const tool = new MockRemoteTool({ name: 'tool_a', sourceId: 'server-A' }); + const context = makeContext({ + stepDefinition: makeStep({ mcpServerId: 'server-B' }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); + }); + }); + + describe('McpToolNotFoundError', () => { + it('returns error when tool from pendingData no longer exists (Branch A)', async () => { + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'deleted_tool', input: {} }, + }; + const tool = new MockRemoteTool({ name: 'other_tool', sourceId: 'mcp-server-1' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI selected a tool that doesn't exist. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('StepPersistenceError', () => { + it('returns error and logs cause when saveStepExecution fails after tool invocation (Branch B)', async () => { + const invokeFn = jest.fn().mockResolvedValue('ok'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'Step side effect succeeded but persistence failed', + expect.objectContaining({ cause: 'Disk full' }), + ); + }); + + it('returns error and logs cause when saveStepExecution fails after tool invocation (Branch A)', async () => { + const invokeFn = jest.fn().mockResolvedValue('ok'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore, userConfirmed: true, logger }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'Step side effect succeeded but persistence failed', + expect.objectContaining({ cause: 'Disk full' }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex', async () => { + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const { model } = makeMockModel('send_notification', {}); + const context = makeContext({ + model, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'mcp-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('no pending data in confirmation flow (Branch A)', () => { + it('returns error when no execution record is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, []); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + }); + + it('returns error when execution exists but pendingData is absent', async () => { + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, []); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + }); + }); + + describe('tool.base.invoke error', () => { + it('returns error when tool invocation throws a WorkflowExecutorError', async () => { + const invokeFn = jest.fn().mockRejectedValue(new StepStateError('Tool failed')); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', {}); + const mockRunStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore: mockRunStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(mockRunStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error and logs when tool invocation throws an infrastructure error', async () => { + const invokeFn = jest.fn().mockRejectedValue(new Error('Connection refused')); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', {}); + const logger = { error: jest.fn() }; + const context = makeContext({ + model, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The tool failed to execute. Please try again or contact your administrator.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'MCP tool invocation failed', + expect.objectContaining({ toolName: 'send_notification', error: 'Connection refused' }), + ); + }); + }); + + describe('selectTool AI errors', () => { + it('returns error when AI returns a malformed tool call (MalformedToolCallError)', async () => { + const model = { + bindTools: jest.fn().mockReturnValue({ + invoke: jest.fn().mockResolvedValue({ + tool_calls: [{ name: 'send_notification', args: null, id: 'call_1' }], + }), + }), + } as unknown as ExecutionContext['model']; + const tool = new MockRemoteTool({ name: 'send_notification' }); + const context = makeContext({ model }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); + }); + + it('returns error when AI returns no tool call (MissingToolCallError)', async () => { + const model = { + bindTools: jest.fn().mockReturnValue({ + invoke: jest.fn().mockResolvedValue({ tool_calls: [] }), + }), + } as unknown as ExecutionContext['model']; + const tool = new MockRemoteTool({ name: 'send_notification' }); + const context = makeContext({ model }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const { model, invoke: modelInvoke } = makeMockModel('send_notification', {}); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ + model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + await executor.execute(); + + const messages = modelInvoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Execute the relevant tool.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in selectTool messages', async () => { + const { model, invoke: modelInvoke } = makeMockModel('send_notification', {}); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we send a notification?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new McpTaskStepExecutor({ ...context, stepId: 'mcp-2', stepIndex: 1 }, [ + tool, + ]); + + await executor.execute(); + + const messages = modelInvoke.mock.calls[0][0]; + // previous steps message + system prompt + human message = 3 + expect(messages).toHaveLength(3); + expect(messages[0].content).toContain('Should we send a notification?'); + }); + }); +}); 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 7a9c6ec9d9..310bf23283 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 @@ -112,7 +112,6 @@ function makeContext( workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: { error: jest.fn() }, ...overrides, }; 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 61a313a213..debd9783cb 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 @@ -107,7 +107,6 @@ function makeContext( workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: { error: jest.fn() }, ...overrides, }; 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 2b2bf7fcec..51f3b04a47 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 @@ -113,7 +113,6 @@ function makeContext( workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), previousSteps: [], - remoteTools: [], logger: { error: jest.fn() }, ...overrides, }; From 02c5af634c6722430b5e2cc6ae0648ed83a7e68b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 09:59:33 +0100 Subject: [PATCH 18/34] refactor(workflow-executor): centralise cause logging in BaseStepExecutor Any WorkflowExecutorError with a cause is now logged automatically by the base executor using error.message as the log message. Removes the manual logger.error call from McpTaskStepExecutor and the StepPersistenceError- specific guard. Moves cause? up to the base class, removing duplicate declarations from StepPersistenceError and McpToolInvocationError. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/errors.ts | 32 +++++++++++-- .../src/executors/base-step-executor.ts | 47 ++++++++++++------- .../src/executors/mcp-task-step-executor.ts | 6 --- .../test/executors/base-step-executor.test.ts | 29 +++++++++++- .../executors/mcp-task-step-executor.test.ts | 16 +++---- 5 files changed, 94 insertions(+), 36 deletions(-) diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index efe3cd85ec..37b5d08cc5 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -2,6 +2,7 @@ export abstract class WorkflowExecutorError extends Error { readonly userMessage: string; + cause?: unknown; constructor(message: string, userMessage?: string) { super(message); @@ -87,10 +88,6 @@ export class NoActionsError extends WorkflowExecutorError { * but the resulting state could not be persisted to the RunStore. */ export class StepPersistenceError extends WorkflowExecutorError { - // Not readonly — allows standard Error.cause semantics without shadowing the built-in with a - // stricter modifier that would prevent downstream code from re-assigning if needed. - cause?: unknown; - constructor(message: string, cause?: unknown) { super(message, 'The step result could not be saved. Please retry.'); if (cause !== undefined) this.cause = cause; @@ -158,3 +155,30 @@ export class StepStateError extends WorkflowExecutorError { super(message, 'An unexpected error occurred while processing this step.'); } } + +export class NoMcpToolsError extends WorkflowExecutorError { + constructor() { + super('No MCP tools available', 'No tools are available to execute this step.'); + } +} + +export class McpToolNotFoundError extends WorkflowExecutorError { + constructor(name: string) { + super( + `MCP tool "${name}" not found`, + "The AI selected a tool that doesn't exist. Try rephrasing the step's prompt.", + ); + } +} + +export class McpToolInvocationError extends WorkflowExecutorError { + constructor(toolName: string, cause: unknown) { + super( + `MCP tool "${toolName}" invocation failed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + 'The tool failed to execute. Please try again or contact your administrator.', + ); + this.cause = cause; + } +} diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 6b191038ce..a79d7cf3d6 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -2,7 +2,8 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { BaseStepStatus } from '../types/step-outcome'; -import type { AIMessage, BaseMessage } from '@langchain/core/messages'; +import type { BaseMessage } from '@langchain/core/messages'; +import type { StructuredToolInterface } from '@langchain/core/tools'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; @@ -31,6 +32,16 @@ export default abstract class BaseStepExecutor>( + protected async invokeWithTools>( messages: BaseMessage[], - tool: DynamicStructuredTool, - ): Promise { - const modelWithTool = this.context.model.bindTools([tool], { tool_choice: 'any' }); - const response = await modelWithTool.invoke(messages); - - return this.extractToolCallArgs(response); - } - - /** - * Extracts the first tool call's args from an AI response. - * Throws if the AI returned a malformed tool call (invalid_tool_calls) or no tool call at all. - */ - private extractToolCallArgs>(response: AIMessage): T { + tools: StructuredToolInterface[], + ): Promise<{ toolName: string; args: T }> { + const modelWithTools = this.context.model.bindTools(tools, { tool_choice: 'any' }); + const response = await modelWithTools.invoke(messages); const toolCall = response.tool_calls?.[0]; if (toolCall !== undefined) { if (toolCall.args !== undefined && toolCall.args !== null) { - return toolCall.args as T; + return { toolName: toolCall.name, args: toolCall.args as T }; } throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'args field is missing or null'); @@ -125,6 +127,17 @@ export default abstract class BaseStepExecutor>( + messages: BaseMessage[], + tool: DynamicStructuredTool, + ): Promise { + return (await this.invokeWithTools(messages, [tool])).args; + } + /** Returns baseRecordRef + any related records loaded by previous steps. */ protected async getAvailableRecordRefs(): Promise { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts index 3a4ae4692a..3aafc5c7a4 100644 --- a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -80,12 +80,6 @@ export default class McpTaskStepExecutor extends RecordTaskStepExecutor { expect(result.stepOutcome.status).toBe('error'); }); }); + + it('logs cause when WorkflowExecutorError has a cause', async () => { + const logger = makeMockLogger(); + const cause = new Error('db timeout'); + const error = new StepPersistenceError('write failed', cause); + const executor = new TestableExecutor(makeContext({ logger }), error); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'write failed', + expect.objectContaining({ + cause: 'db timeout', + stack: cause.stack, + }), + ); + }); + + it('does not log when WorkflowExecutorError has no cause', async () => { + const logger = makeMockLogger(); + const executor = new TestableExecutor(makeContext({ logger }), new MissingToolCallError()); + await executor.execute(); + expect(logger.error).not.toHaveBeenCalled(); + }); }); describe('invokeWithTool', () => { diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts index ddabbb9ddc..d1502e65a6 100644 --- a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -173,8 +173,8 @@ describe('McpTaskStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'Step side effect succeeded but persistence failed', - expect.objectContaining({ cause: 'DB unavailable' }), + 'MCP task step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'DB unavailable', stepId: 'mcp-1' }), ); }); }); @@ -353,8 +353,8 @@ describe('McpTaskStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'Step side effect succeeded but persistence failed', - expect.objectContaining({ cause: 'Disk full' }), + 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), ); }); @@ -383,8 +383,8 @@ describe('McpTaskStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); expect(logger.error).toHaveBeenCalledWith( - 'Step side effect succeeded but persistence failed', - expect.objectContaining({ cause: 'Disk full' }), + 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), ); }); }); @@ -492,8 +492,8 @@ describe('McpTaskStepExecutor', () => { 'The tool failed to execute. Please try again or contact your administrator.', ); expect(logger.error).toHaveBeenCalledWith( - 'MCP tool invocation failed', - expect.objectContaining({ toolName: 'send_notification', error: 'Connection refused' }), + 'MCP tool "send_notification" invocation failed: Connection refused', + expect.objectContaining({ cause: 'Connection refused' }), ); }); }); From e4db8416705f5bc69c926b1fccb27a3f9a0c5b7a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 10:27:50 +0100 Subject: [PATCH 19/34] feat(workflow-executor): add agentPortError and safe-agent-port wrapper Add AgentPortError and SafeAgentPort to centralise infra error handling for all agentPort operations (getRecord, updateRecord, getRelatedData, executeAction). - add AgentPortError extending WorkflowExecutorError with a user-friendly message and structured cause logging - add SafeAgentPort implementing AgentPort: wraps infra errors in AgentPortError, passes through WorkflowExecutorError subclasses unchanged - expose this.agentPort (SafeAgentPort) as a protected property in BaseStepExecutor; all executors use it instead of this.context.agentPort - export AgentPortError from index.ts - add unit tests for SafeAgentPort and one integration test per executor verifying userMessage and logger.error cause on infra failures Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/errors.ts | 10 + .../src/executors/base-step-executor.ts | 5 + .../load-related-record-step-executor.ts | 4 +- .../executors/read-record-step-executor.ts | 2 +- .../src/executors/safe-agent-port.ts | 39 ++++ .../trigger-record-action-step-executor.ts | 2 +- .../executors/update-record-step-executor.ts | 2 +- packages/workflow-executor/src/index.ts | 9 + .../load-related-record-step-executor.test.ts | 25 +++ .../read-record-step-executor.test.ts | 20 ++ .../test/executors/safe-agent-port.test.ts | 173 ++++++++++++++++++ ...rigger-record-action-step-executor.test.ts | 28 +++ .../update-record-step-executor.test.ts | 29 +++ 13 files changed, 343 insertions(+), 5 deletions(-) create mode 100644 packages/workflow-executor/src/executors/safe-agent-port.ts create mode 100644 packages/workflow-executor/test/executors/safe-agent-port.test.ts diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 37b5d08cc5..638d7c4e47 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -171,6 +171,16 @@ export class McpToolNotFoundError extends WorkflowExecutorError { } } +export class AgentPortError extends WorkflowExecutorError { + constructor(operation: string, cause: unknown) { + super( + `Agent port "${operation}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, + 'An error occurred while accessing your data. Please try again.', + ); + this.cause = cause; + } +} + export class McpToolInvocationError extends WorkflowExecutorError { constructor(toolName: string, cause: unknown) { super( diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index a79d7cf3d6..54efae285f 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,3 +1,4 @@ +import type { AgentPort } from '../ports/agent-port'; import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; @@ -16,15 +17,19 @@ import { NoRecordsError, WorkflowExecutorError, } from '../errors'; +import SafeAgentPort from './safe-agent-port'; import StepSummaryBuilder from './summary/step-summary-builder'; export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; + protected readonly agentPort: AgentPort; + protected readonly schemaCache = new Map(); constructor(context: ExecutionContext) { this.context = context; + this.agentPort = new SafeAgentPort(context.agentPort); } async execute(): Promise { 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 284a3b3c08..c5c645aa29 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 @@ -161,7 +161,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto ): Promise<{ relatedData: RecordData[]; bestIndex: number; suggestedFields: string[] }> { const { selectedRecordRef, name } = target; - const relatedData = await this.context.agentPort.getRelatedData({ + const relatedData = await this.agentPort.getRelatedData({ collection: selectedRecordRef.collectionName, id: selectedRecordRef.recordId, relation: name, @@ -213,7 +213,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto limit: number, ): Promise { const { selectedRecordRef, name } = target; - const relatedData = await this.context.agentPort.getRelatedData({ + const relatedData = await this.agentPort.getRelatedData({ collection: selectedRecordRef.collectionName, id: selectedRecordRef.recordId, relation: name, diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 9d3834b2c6..49b34fb65e 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -34,7 +34,7 @@ export default class ReadRecordStepExecutor extends RecordTaskStepExecutor { + return this.call('getRecord', () => this.port.getRecord(query)); + } + + async updateRecord(query: UpdateRecordQuery): Promise { + return this.call('updateRecord', () => this.port.updateRecord(query)); + } + + async getRelatedData(query: GetRelatedDataQuery): Promise { + return this.call('getRelatedData', () => this.port.getRelatedData(query)); + } + + async executeAction(query: ExecuteActionQuery): Promise { + return this.call('executeAction', () => this.port.executeAction(query)); + } + + private async call(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AgentPortError(operation, cause); + } + } +} 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 cd2f81925d..928f08a974 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 @@ -85,7 +85,7 @@ export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecu ): Promise { const { selectedRecordRef, displayName, name } = target; - const actionResult = await this.context.agentPort.executeAction({ + const actionResult = await this.agentPort.executeAction({ collection: selectedRecordRef.collectionName, action: name, id: selectedRecordRef.recordId, 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 c54c34e9dc..6c2383fb12 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -95,7 +95,7 @@ export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { const { selectedRecordRef, displayName, name, value } = target; - const updated = await this.context.agentPort.updateRecord({ + const updated = await this.agentPort.updateRecord({ collection: selectedRecordRef.collectionName, id: selectedRecordRef.recordId, values: { [name]: value }, diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 565063bd99..d323d450ac 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -2,6 +2,7 @@ export { StepType } from './types/step-definition'; export type { ConditionStepDefinition, RecordTaskStepDefinition, + McpTaskStepDefinition, StepDefinition, } from './types/step-definition'; @@ -26,6 +27,9 @@ export type { RecordTaskStepExecutionData, LoadRelatedRecordPendingData, LoadRelatedRecordStepExecutionData, + McpToolRef, + McpToolCall, + McpTaskStepExecutionData, ExecutedStepExecutionData, StepExecutionData, } from './types/step-execution-data'; @@ -77,6 +81,10 @@ export { FieldNotFoundError, ActionNotFoundError, StepStateError, + NoMcpToolsError, + McpToolNotFoundError, + McpToolInvocationError, + AgentPortError, } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; @@ -84,6 +92,7 @@ export { default as ReadRecordStepExecutor } from './executors/read-record-step- export { default as UpdateRecordStepExecutor } from './executors/update-record-step-executor'; export { default as TriggerRecordActionStepExecutor } from './executors/trigger-record-action-step-executor'; export { default as LoadRelatedRecordStepExecutor } from './executors/load-related-record-step-executor'; +export { default as McpTaskStepExecutor } from './executors/mcp-task-step-executor'; export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port'; export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; export { default as ExecutorHttpServer } from './http/executor-http-server'; 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 330a72a888..bcd5f1dc0c 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 @@ -1107,6 +1107,31 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); }); + + it('returns user message and logs cause when agentPort.getRelatedData throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "getRelatedData" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); }); describe('multi-record AI selection (base record pool)', () => { 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 310bf23283..bc4c0696ab 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 @@ -651,6 +651,26 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); }); + + it('returns user message and logs cause when agentPort.getRecord throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ fieldNames: ['email'] }); + const context = makeContext({ model: mockModel.model, agentPort, logger }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "getRecord" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); }); describe('model error', () => { diff --git a/packages/workflow-executor/test/executors/safe-agent-port.test.ts b/packages/workflow-executor/test/executors/safe-agent-port.test.ts new file mode 100644 index 0000000000..33ab53d072 --- /dev/null +++ b/packages/workflow-executor/test/executors/safe-agent-port.test.ts @@ -0,0 +1,173 @@ +import type { AgentPort } from '../../src/ports/agent-port'; + +import { AgentPortError, StepStateError, WorkflowExecutorError } from '../../src/errors'; +import SafeAgentPort from '../../src/executors/safe-agent-port'; + +function makeMockPort(overrides: Partial = {}): AgentPort { + return { + getRecord: jest + .fn() + .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), + updateRecord: jest + .fn() + .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), + getRelatedData: jest.fn().mockResolvedValue([]), + executeAction: jest.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as AgentPort; +} + +describe('SafeAgentPort', () => { + describe('returns result when port call succeeds', () => { + it('getRecord returns the port result', async () => { + const expected = { collectionName: 'customers', recordId: [1], values: { email: 'a@b.com' } }; + const port = makeMockPort({ getRecord: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.getRecord({ collection: 'customers', id: [1] }); + + expect(result).toBe(expected); + }); + + it('updateRecord returns the port result', async () => { + const expected = { collectionName: 'customers', recordId: [1], values: { status: 'active' } }; + const port = makeMockPort({ updateRecord: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.updateRecord({ + collection: 'customers', + id: [1], + values: { status: 'active' }, + }); + + expect(result).toBe(expected); + }); + + it('getRelatedData returns the port result', async () => { + const expected = [{ collectionName: 'orders', recordId: [10], values: {} }]; + const port = makeMockPort({ getRelatedData: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.getRelatedData({ + collection: 'customers', + id: [1], + relation: 'orders', + limit: 10, + }); + + expect(result).toBe(expected); + }); + + it('executeAction returns the port result', async () => { + const expected = { success: true }; + const port = makeMockPort({ executeAction: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.executeAction({ collection: 'customers', action: 'send-email' }); + + expect(result).toBe(expected); + }); + }); + + describe('wraps infra Error in AgentPortError', () => { + it('wraps getRecord infra error with correct operation name', async () => { + const port = makeMockPort({ + getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), + }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toThrow( + AgentPortError, + ); + }); + + it('includes cause message in AgentPortError.message for getRecord', async () => { + const port = makeMockPort({ + getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), + }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toThrow( + 'Agent port "getRecord" failed: DB connection lost', + ); + }); + + it('wraps updateRecord infra error with correct operation name', async () => { + const port = makeMockPort({ + updateRecord: jest.fn().mockRejectedValue(new Error('Timeout')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.updateRecord({ collection: 'customers', id: [1], values: {} }), + ).rejects.toThrow('Agent port "updateRecord" failed: Timeout'); + }); + + it('wraps getRelatedData infra error with correct operation name', async () => { + const port = makeMockPort({ + getRelatedData: jest.fn().mockRejectedValue(new Error('Network error')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.getRelatedData({ collection: 'customers', id: [1], relation: 'orders', limit: 10 }), + ).rejects.toThrow('Agent port "getRelatedData" failed: Network error'); + }); + + it('wraps executeAction infra error with correct operation name', async () => { + const port = makeMockPort({ + executeAction: jest.fn().mockRejectedValue(new Error('Action failed')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.executeAction({ collection: 'customers', action: 'send-email' }), + ).rejects.toThrow('Agent port "executeAction" failed: Action failed'); + }); + + it('sets cause on AgentPortError', async () => { + const infraError = new Error('DB connection lost'); + const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(infraError) }); + const safe = new SafeAgentPort(port); + + let thrown: unknown; + + try { + await safe.getRecord({ collection: 'customers', id: [1] }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(AgentPortError); + expect((thrown as AgentPortError).cause).toBe(infraError); + }); + }); + + describe('does not re-wrap WorkflowExecutorError', () => { + it('rethrows WorkflowExecutorError as-is from getRecord', async () => { + const domainError = new StepStateError('invalid state'); + const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(domainError) }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toBe(domainError); + }); + + it('rethrows WorkflowExecutorError subclass without wrapping in AgentPortError', async () => { + const domainError = new StepStateError('invalid state'); + const port = makeMockPort({ executeAction: jest.fn().mockRejectedValue(domainError) }); + const safe = new SafeAgentPort(port); + + let thrown: unknown; + + try { + await safe.executeAction({ collection: 'customers', action: 'send-email' }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(WorkflowExecutorError); + expect(thrown).not.toBeInstanceOf(AgentPortError); + expect(thrown).toBe(domainError); + }); + }); +}); 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 debd9783cb..f67fd3af2a 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 @@ -508,6 +508,34 @@ describe('TriggerRecordActionStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); }); + + it('returns user message and logs cause when agentPort.executeAction throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "executeAction" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); }); describe('displayName → name resolution', () => { 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 51f3b04a47..dc8fffd563 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 @@ -653,6 +653,35 @@ describe('UpdateRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); }); + + it('returns user message and logs cause when agentPort.updateRecord throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "updateRecord" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); }); describe('stepOutcome shape', () => { From 699ce28e0e2130d684babe301470f8f27ccc0572 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 10:38:01 +0100 Subject: [PATCH 20/34] feat(workflow-executor): add McpTaskStepExecutionData types and mcp-task dependencies Add McpToolRef, McpToolCall and McpTaskStepExecutionData to step-execution-data, required by mcp-task-step-executor. Update package.json to depend on @forestadmin/ai-proxy and align @langchain/core version. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/package.json | 3 +- .../executors/record-task-step-executor.ts | 3 +- .../src/types/step-execution-data.ts | 22 ++++++++++- yarn.lock | 39 ------------------- 4 files changed, 24 insertions(+), 43 deletions(-) diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index cf94a502ea..bda0e14ce1 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -23,10 +23,11 @@ "test": "jest" }, "dependencies": { + "@forestadmin/ai-proxy": "1.6.1", + "@langchain/core": "1.1.15", "@forestadmin/agent-client": "1.4.13", "@forestadmin/forestadmin-client": "1.37.17", "@koa/router": "^13.1.0", - "@langchain/core": "1.1.33", "koa": "^3.0.1", "zod": "4.3.6" }, diff --git a/packages/workflow-executor/src/executors/record-task-step-executor.ts b/packages/workflow-executor/src/executors/record-task-step-executor.ts index 7b7322f051..f0067cae73 100644 --- a/packages/workflow-executor/src/executors/record-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-task-step-executor.ts @@ -1,5 +1,4 @@ import type { StepExecutionResult } from '../types/execution'; -import type { RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { StepExecutionData } from '../types/step-execution-data'; import type { RecordTaskStepStatus } from '../types/step-outcome'; @@ -8,7 +7,7 @@ import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; /** Execution data that includes the fields required by the confirmation flow. */ -type WithPendingData = StepExecutionData & { pendingData?: object; selectedRecordRef: RecordRef }; +type WithPendingData = StepExecutionData & { pendingData?: object }; export default abstract class RecordTaskStepExecutor< TStep extends StepDefinition = StepDefinition, diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index f76fa6bead..d71190b450 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -78,6 +78,25 @@ export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionD selectedRecordRef: RecordRef; } +// -- Mcp Task -- + +/** Reference to an MCP tool by its sanitized name (OpenAI-safe, alphanumeric + underscores/hyphens). */ +export interface McpToolRef { + name: string; +} + +/** A resolved tool call: sanitized tool name + input parameters sent to the tool. */ +export interface McpToolCall extends McpToolRef { + input: Record; +} + +export interface McpTaskStepExecutionData extends BaseStepExecutionData { + type: 'mcp-task'; + executionParams?: McpToolCall; + executionResult?: { success: true; toolResult: unknown } | { skipped: true }; + pendingData?: McpToolCall; +} + // -- Generic AI Task (fallback for untyped steps) -- export interface RecordTaskStepExecutionData extends BaseStepExecutionData { @@ -125,7 +144,8 @@ export type StepExecutionData = | UpdateRecordStepExecutionData | TriggerRecordActionStepExecutionData | RecordTaskStepExecutionData - | LoadRelatedRecordStepExecutionData; + | LoadRelatedRecordStepExecutionData + | McpTaskStepExecutionData; /** Alias for StepExecutionData — kept for backwards-compatible consumption at the call sites. */ export type ExecutedStepExecutionData = StepExecutionData; diff --git a/yarn.lock b/yarn.lock index c7c7add7ea..371ee67d9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2314,23 +2314,6 @@ uuid "^10.0.0" zod "^3.25.76 || ^4" -"@langchain/core@1.1.33": - version "1.1.33" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.33.tgz#414536e9d0a6f90576502e532336104360ed4392" - integrity sha512-At1ooBmPlHMkhTkG6NqeOVjNscuJwneBB8F88rFRvBvIfhTACVLzEwMiZFWNTM8DzUXUOcxxqS7xKRyr6JBbOQ== - dependencies: - "@cfworker/json-schema" "^4.0.2" - "@standard-schema/spec" "^1.1.0" - ansi-styles "^5.0.0" - camelcase "6" - decamelize "1.2.0" - js-tiktoken "^1.0.12" - langsmith ">=0.5.0 <1.0.0" - mustache "^4.2.0" - p-queue "^6.6.2" - uuid "^11.1.0" - zod "^3.25.76 || ^4" - "@langchain/langgraph-checkpoint@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" @@ -4186,11 +4169,6 @@ dependencies: tslib "^2.6.2" -"@standard-schema/spec@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" - integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== - "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -11319,18 +11297,6 @@ koa@^3.0.1: semver "^7.6.3" uuid "^10.0.0" -"langsmith@>=0.5.0 <1.0.0": - version "0.5.10" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.10.tgz#f0df23538e6a7c2928787030cedfb4be9d5b3db6" - integrity sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A== - dependencies: - "@types/uuid" "^10.0.0" - chalk "^5.6.2" - console-table-printer "^2.12.1" - p-queue "^6.6.2" - semver "^7.6.3" - uuid "^10.0.0" - lerna@^8.2.3: version "8.2.3" resolved "https://registry.yarnpkg.com/lerna/-/lerna-8.2.3.tgz#0a9c07eda4cfac84a480b3e66915189ccfb5bd2c" @@ -17322,11 +17288,6 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" - integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== - uuid@^13.0.0: version "13.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" From b370b0ae8a776a0dbf77264b6ea00b94043f838b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 10:41:43 +0100 Subject: [PATCH 21/34] feat(ai-proxy): re-export RemoteTool and langchain core types Export RemoteTool default, BaseChatModel, BaseMessage, HumanMessage, SystemMessage, StructuredToolInterface and DynamicStructuredTool so consumers of ai-proxy do not need a direct @langchain/core dependency. Co-Authored-By: Claude Sonnet 4.6 --- packages/ai-proxy/src/index.ts | 7 +++++++ packages/ai-proxy/test/ai-client.test.ts | 12 +++--------- packages/ai-proxy/test/errors.test.ts | 2 +- packages/ai-proxy/test/langchain-adapter.test.ts | 9 ++------- packages/ai-proxy/test/provider-dispatcher.test.ts | 8 ++------ 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index c355e0f9bb..6fa9cb86a5 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -8,6 +8,7 @@ export { default as ProviderDispatcher } from './provider-dispatcher'; export * from './provider-dispatcher'; export * from './ai-client'; export * from './remote-tools'; +export { default as RemoteTool } from './remote-tool'; export * from './router'; export * from './mcp-client'; export * from './oauth-token-injector'; @@ -16,3 +17,9 @@ export * from './errors'; export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { return McpConfigChecker.check(mcpConfig); } + +export type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +export type { BaseMessage } from '@langchain/core/messages'; +export { HumanMessage, SystemMessage } from '@langchain/core/messages'; +export type { StructuredToolInterface } from '@langchain/core/tools'; +export { DynamicStructuredTool } from '@langchain/core/tools'; diff --git a/packages/ai-proxy/test/ai-client.test.ts b/packages/ai-proxy/test/ai-client.test.ts index 6c6929dd2c..f926eecbf2 100644 --- a/packages/ai-proxy/test/ai-client.test.ts +++ b/packages/ai-proxy/test/ai-client.test.ts @@ -23,9 +23,7 @@ describe('Model validation', () => { expect( () => new AiClient({ - aiConfigurations: [ - { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4' }, - ], + aiConfigurations: [{ name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4' }], }), ).toThrow(AIModelNotSupportedError); }); @@ -34,9 +32,7 @@ describe('Model validation', () => { expect( () => new AiClient({ - aiConfigurations: [ - { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, - ], + aiConfigurations: [{ name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }], }), ).not.toThrow(); }); @@ -143,9 +139,7 @@ describe('getModel', () => { 'Warn', expect.stringContaining("AI configuration 'non-existent' not found"), ); - expect(createBaseChatModelMock).toHaveBeenCalledWith( - expect.objectContaining({ name: 'gpt4' }), - ); + expect(createBaseChatModelMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'gpt4' })); expect(result).toBe(fakeModel); }); }); diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index c0817275a9..8cfab73fec 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -17,9 +17,9 @@ import { AIProviderError, AIProviderUnavailableError, AITooManyRequestsError, - AIUnauthorizedError, AIToolNotFoundError, AIToolUnprocessableError, + AIUnauthorizedError, McpConfigError, McpConflictError, McpConnectionError, diff --git a/packages/ai-proxy/test/langchain-adapter.test.ts b/packages/ai-proxy/test/langchain-adapter.test.ts index cc7ca89aea..1adfd64fe7 100644 --- a/packages/ai-proxy/test/langchain-adapter.test.ts +++ b/packages/ai-proxy/test/langchain-adapter.test.ts @@ -90,15 +90,11 @@ describe('LangChainAdapter', () => { { role: 'assistant', content: '', - tool_calls: [ - { id: 'call_1', function: { name: 'my_tool', arguments: 'not-json' } }, - ], + tool_calls: [{ id: 'call_1', function: { name: 'my_tool', arguments: 'not-json' } }], }, ]), ).toThrow( - new AIBadRequestError( - "Invalid JSON in tool_calls arguments for tool 'my_tool': not-json", - ), + new AIBadRequestError("Invalid JSON in tool_calls arguments for tool 'my_tool': not-json"), ); }); }); @@ -256,5 +252,4 @@ describe('LangChainAdapter', () => { ); }); }); - }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 7cefcf3dc4..0138fa0fda 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -181,9 +181,7 @@ describe('ProviderDispatcher', () => { const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); expect(thrown).toBeInstanceOf(AIProviderUnavailableError); - expect(thrown.message).toBe( - 'OpenAI server error (HTTP 500): Internal Server Error', - ); + expect(thrown.message).toBe('OpenAI server error (HTTP 500): Internal Server Error'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.providerStatusCode).toBe(500); expect(thrown.baseBusinessErrorName).toBe('InternalServerError'); @@ -468,9 +466,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIProviderUnavailableError); - expect(thrown.message).toBe( - 'Anthropic server error (HTTP 503): Service Unavailable', - ); + expect(thrown.message).toBe('Anthropic server error (HTTP 503): Service Unavailable'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.providerStatusCode).toBe(503); expect(thrown.baseBusinessErrorName).toBe('InternalServerError'); From 581ecfdd791c340f3b36c598f38c87548c9be725 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 10:55:46 +0100 Subject: [PATCH 22/34] refactor(workflow-executor): replace @langchain/core imports with @forestadmin/ai-proxy Replace all direct @langchain/core imports in src and tests with @forestadmin/ai-proxy re-exports. Add moduleNameMapper in jest.config.ts to resolve @anthropic-ai/sdk wildcard exports (Jest < 30 workaround, same pattern as ai-proxy). Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/jest.config.ts | 9 +++++++++ .../src/executors/base-step-executor.ts | 6 ++---- .../src/executors/condition-step-executor.ts | 3 +-- .../src/executors/load-related-record-step-executor.ts | 3 +-- .../src/executors/mcp-task-step-executor.ts | 2 +- .../src/executors/read-record-step-executor.ts | 3 +-- .../src/executors/trigger-record-action-step-executor.ts | 3 +-- .../src/executors/update-record-step-executor.ts | 3 +-- packages/workflow-executor/src/types/execution.ts | 2 +- .../test/executors/base-step-executor.test.ts | 5 ++--- 10 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/workflow-executor/jest.config.ts b/packages/workflow-executor/jest.config.ts index d622773e8a..695cb0997c 100644 --- a/packages/workflow-executor/jest.config.ts +++ b/packages/workflow-executor/jest.config.ts @@ -1,8 +1,17 @@ /* eslint-disable import/no-relative-packages */ +import path from 'path'; + import jestConfig from '../../jest.config'; +// Jest < 30 doesn't resolve wildcard exports in package.json. +// @anthropic-ai/sdk uses "./lib/*" exports that need this workaround. +const anthropicSdkDir = path.dirname(require.resolve('@anthropic-ai/sdk')); + export default { ...jestConfig, collectCoverageFrom: ['/src/**/*.ts'], testMatch: ['/test/**/*.test.ts'], + moduleNameMapper: { + '^@anthropic-ai/sdk/(.*)$': `${anthropicSdkDir}/$1`, + }, }; diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 54efae285f..7c50bd7771 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -3,11 +3,9 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { BaseStepStatus } from '../types/step-outcome'; -import type { BaseMessage } from '@langchain/core/messages'; -import type { StructuredToolInterface } from '@langchain/core/tools'; +import type { BaseMessage, StructuredToolInterface } from '@forestadmin/ai-proxy'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index bd6288f380..43fd995e3c 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -2,8 +2,7 @@ import type { StepExecutionResult } from '../types/execution'; import type { ConditionStepDefinition } from '../types/step-definition'; import type { ConditionStepStatus } from '../types/step-outcome'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { StepPersistenceError } from '../errors'; 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 c5c645aa29..904037723d 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 @@ -3,8 +3,7 @@ import type { CollectionSchema, RecordData, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { LoadRelatedRecordStepExecutionData, RelationRef } from '../types/step-execution-data'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts index 3aafc5c7a4..4180f7117c 100644 --- a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -3,7 +3,7 @@ import type { McpTaskStepDefinition } from '../types/step-definition'; import type { McpTaskStepExecutionData, McpToolCall } from '../types/step-execution-data'; import type { RemoteTool } from '@forestadmin/ai-proxy'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { McpToolInvocationError, diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 49b34fb65e..4b8622014a 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -3,8 +3,7 @@ import type { CollectionSchema } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult } from '../types/step-execution-data'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { NoReadableFieldsError, NoResolvedFieldsError } from '../errors'; 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 928f08a974..167e3f018c 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 @@ -3,8 +3,7 @@ import type { CollectionSchema, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { ActionNotFoundError, NoActionsError, StepPersistenceError } from '../errors'; 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 6c2383fb12..97c0cb1d41 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -3,8 +3,7 @@ import type { CollectionSchema, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { FieldRef, UpdateRecordStepExecutionData } from '../types/step-execution-data'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; import { FieldNotFoundError, NoWritableFieldsError, StepPersistenceError } from '../errors'; diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 59c4ed8420..3df0671807 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -7,7 +7,7 @@ import type { AgentPort } from '../ports/agent-port'; import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { BaseChatModel } from '@forestadmin/ai-proxy'; export interface Step { stepDefinition: StepDefinition; 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 5eb3a3cc87..fcf1ab1c45 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -6,10 +6,9 @@ import type { RecordRef } from '../../src/types/record'; import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; import type { BaseStepStatus, StepOutcome } from '../../src/types/step-outcome'; -import type { BaseMessage } from '@langchain/core/messages'; -import type { DynamicStructuredTool } from '@langchain/core/tools'; +import type { BaseMessage, DynamicStructuredTool } from '@forestadmin/ai-proxy'; -import { SystemMessage } from '@langchain/core/messages'; +import { SystemMessage } from '@forestadmin/ai-proxy'; import { MalformedToolCallError, From 369a39858d3f183a291ece5159aebb4d29517a1e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 11:06:29 +0100 Subject: [PATCH 23/34] chore(workflow-executor): remove direct @langchain/core dependency @langchain/core is now consumed transitively via @forestadmin/ai-proxy. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index bda0e14ce1..4a6a93b0a4 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -24,7 +24,6 @@ }, "dependencies": { "@forestadmin/ai-proxy": "1.6.1", - "@langchain/core": "1.1.15", "@forestadmin/agent-client": "1.4.13", "@forestadmin/forestadmin-client": "1.37.17", "@koa/router": "^13.1.0", From 013560e4acba666300630f54649e25b29903e3d6 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 14:50:55 +0100 Subject: [PATCH 24/34] feat(workflow-executor): implement Runner polling loop and step dispatch - Implement polling loop with configurable interval and auto-reschedule - Add getExecutor() factory dispatching all StepTypes to their executor - Add triggerPoll() for webhook-driven execution - Add in-flight step deduplication via inFlightSteps Set - Add MCP lazy tool loading with once() thunk - Replace McpConfiguration placeholder type with @forestadmin/ai-proxy type - Fix missing stepIndex field in step-scoped error logs - Fix missing cause field in unexpected error logs in BaseStepExecutor - Add comprehensive test coverage for Runner Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/base-step-executor.ts | 2 + .../src/ports/workflow-port.ts | 4 +- packages/workflow-executor/src/runner.ts | 219 +++++- .../test/executors/base-step-executor.test.ts | 12 + .../workflow-executor/test/runner.test.ts | 654 ++++++++++++++++-- 5 files changed, 821 insertions(+), 70 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 7c50bd7771..b09a7a8e3a 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -48,11 +48,13 @@ export default abstract class BaseStepExecutor; diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 652772c71c..49db62f0f6 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -1,52 +1,239 @@ -// TODO: implement polling loop, execution dispatch, AI wiring (see spec section 4.1) - +import type BaseStepExecutor from './executors/base-step-executor'; import type { AgentPort } from './ports/agent-port'; +import type { Logger } from './ports/logger-port'; import type { RunStore } from './ports/run-store'; -import type { WorkflowPort } from './ports/workflow-port'; +import type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; +import type { ExecutionContext, PendingStepExecution } from './types/execution'; +import type { + ConditionStepDefinition, + McpTaskStepDefinition, + RecordTaskStepDefinition, +} from './types/step-definition'; +import type { StepOutcome } from './types/step-outcome'; +import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; +import ConsoleLogger from './adapters/console-logger'; +import ConditionStepExecutor from './executors/condition-step-executor'; +import LoadRelatedRecordStepExecutor from './executors/load-related-record-step-executor'; +import McpTaskStepExecutor from './executors/mcp-task-step-executor'; +import ReadRecordStepExecutor from './executors/read-record-step-executor'; +import TriggerRecordActionStepExecutor from './executors/trigger-record-action-step-executor'; +import UpdateRecordStepExecutor from './executors/update-record-step-executor'; import ExecutorHttpServer from './http/executor-http-server'; +import { StepType } from './types/step-definition'; export interface RunnerConfig { agentPort: AgentPort; workflowPort: WorkflowPort; runStore: RunStore; pollingIntervalMs: number; + aiClient: AiClient; + logger?: Logger; httpPort?: number; } +function once(fn: () => Promise): () => Promise { + let cached: Promise | undefined; + + return () => { + cached ??= fn(); + + return cached; + }; +} + +function stepKey(step: PendingStepExecution): string { + return `${step.runId}:${step.stepId}`; +} + +function stepOutcomeType(step: PendingStepExecution): 'condition' | 'record-task' { + return step.stepDefinition.type === StepType.Condition ? 'condition' : 'record-task'; +} + +function causeMessage(error: unknown): string | undefined { + const { cause } = error as { cause?: unknown }; + + return cause instanceof Error ? cause.message : undefined; +} + +export async function getExecutor( + step: PendingStepExecution, + context: ExecutionContext, + loadTools: () => Promise, +): Promise { + switch (step.stepDefinition.type) { + case StepType.Condition: + return new ConditionStepExecutor(context as ExecutionContext); + case StepType.ReadRecord: + return new ReadRecordStepExecutor(context as ExecutionContext); + case StepType.UpdateRecord: + return new UpdateRecordStepExecutor(context as ExecutionContext); + case StepType.TriggerAction: + return new TriggerRecordActionStepExecutor( + context as ExecutionContext, + ); + case StepType.LoadRelatedRecord: + return new LoadRelatedRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.McpTask: + return new McpTaskStepExecutor( + context as ExecutionContext, + await loadTools(), + ); + default: + throw new Error(`Unknown step type: ${(step.stepDefinition as { type: string }).type}`); + } +} + export default class Runner { private readonly config: RunnerConfig; private httpServer: ExecutorHttpServer | null = null; + private pollingTimer: NodeJS.Timeout | null = null; + private readonly inFlightSteps = new Set(); + private isRunning = false; + private readonly logger: Logger; constructor(config: RunnerConfig) { this.config = config; + this.logger = config.logger ?? new ConsoleLogger(); } async start(): Promise { - if (this.config.httpPort !== undefined && !this.httpServer) { - const server = new ExecutorHttpServer({ - port: this.config.httpPort, - runStore: this.config.runStore, - runner: this, - }); - await server.start(); - this.httpServer = server; + if (this.isRunning) return; + this.isRunning = true; + + try { + if (this.config.httpPort !== undefined && !this.httpServer) { + const server = new ExecutorHttpServer({ + port: this.config.httpPort, + runStore: this.config.runStore, + runner: this, + }); + await server.start(); + this.httpServer = server; + } + } catch (error) { + this.isRunning = false; + throw error; } - // TODO: start polling loop + this.schedulePoll(); } async stop(): Promise { + this.isRunning = false; + + if (this.pollingTimer !== null) { + clearTimeout(this.pollingTimer); + this.pollingTimer = null; + } + if (this.httpServer) { await this.httpServer.stop(); this.httpServer = null; } - // TODO: stop polling loop, close connections + await this.config.aiClient.closeConnections(); + + // TODO: graceful drain of in-flight steps (out of scope PRD-223) + } + + async triggerPoll(runId: string): Promise { + const steps = await this.config.workflowPort.getPendingStepExecutions(); + const pending = steps.filter(s => s.runId === runId && !this.inFlightSteps.has(stepKey(s))); + const loadTools = once(() => this.fetchRemoteTools()); + await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); + } + + private schedulePoll(): void { + if (!this.isRunning) return; + this.pollingTimer = setTimeout(() => this.runPollCycle(), this.config.pollingIntervalMs); + } + + private async runPollCycle(): Promise { + try { + const steps = await this.config.workflowPort.getPendingStepExecutions(); + const pending = steps.filter(s => !this.inFlightSteps.has(stepKey(s))); + const loadTools = once(() => this.fetchRemoteTools()); + await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); + } catch (error) { + this.logger.error('Poll cycle failed', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } finally { + this.schedulePoll(); + } + } + + private async fetchRemoteTools(): Promise { + const configs = await this.config.workflowPort.getMcpServerConfigs(); + if (configs.length === 0) return []; + + const mergedConfig: McpConfiguration = { + ...configs[0], + configs: Object.assign({}, ...configs.map(c => c.configs)), + }; + + return this.config.aiClient.loadRemoteTools(mergedConfig); + } + + private async executeStep( + step: PendingStepExecution, + loadTools: () => Promise, + ): Promise { + const key = stepKey(step); + this.inFlightSteps.add(key); + + let stepOutcome: StepOutcome; + + try { + const context = this.buildContext(step); + const executor = await getExecutor(step, context, loadTools); + ({ stepOutcome } = await executor.execute()); + } catch (error) { + this.logger.error('Step execution failed unexpectedly', { + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + stepOutcome = { + type: stepOutcomeType(step), + stepId: step.stepId, + stepIndex: step.stepIndex, + status: 'error', + error: 'An unexpected error occurred.', + }; + } finally { + this.inFlightSteps.delete(key); + } + + try { + await this.config.workflowPort.updateStepExecution(step.runId, stepOutcome); + } catch (error) { + this.logger.error('Failed to report step outcome', { + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } } - // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars - async triggerPoll(_runId: string): Promise { - // TODO: trigger immediate poll cycle for this runId + private buildContext(step: PendingStepExecution): ExecutionContext { + return { + ...step, + model: this.config.aiClient.getModel(step.stepDefinition.aiConfigName), + agentPort: this.config.agentPort, + workflowPort: this.config.workflowPort, + runStore: this.config.runStore, + logger: this.logger, + }; } } 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 fcf1ab1c45..8c3f5c2543 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -231,6 +231,18 @@ describe('BaseStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); }); + + it('includes cause in log when non-WorkflowExecutorError has a cause', async () => { + const logger = makeMockLogger(); + const cause = new Error('root cause'); + const error = Object.assign(new Error('wrapper error'), { cause }); + const executor = new TestableExecutor(makeContext({ logger }), error); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ cause: 'root cause' }), + ); + }); }); it('logs cause when WorkflowExecutorError has a cause', async () => { diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 0ea16bd276..d6f1bcef0c 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -1,96 +1,646 @@ import type { AgentPort } from '../src/ports/agent-port'; +import type { Logger } from '../src/ports/logger-port'; import type { RunStore } from '../src/ports/run-store'; import type { WorkflowPort } from '../src/ports/workflow-port'; +import type { PendingStepExecution } from '../src/types/execution'; +import type { StepDefinition } from '../src/types/step-definition'; +import type { AiClient, BaseChatModel } from '@forestadmin/ai-proxy'; +import BaseStepExecutor from '../src/executors/base-step-executor'; +import ConditionStepExecutor from '../src/executors/condition-step-executor'; +import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; +import McpTaskStepExecutor from '../src/executors/mcp-task-step-executor'; +import ReadRecordStepExecutor from '../src/executors/read-record-step-executor'; +import TriggerRecordActionStepExecutor from '../src/executors/trigger-record-action-step-executor'; +import UpdateRecordStepExecutor from '../src/executors/update-record-step-executor'; import ExecutorHttpServer from '../src/http/executor-http-server'; -import Runner from '../src/runner'; +import Runner, { getExecutor } from '../src/runner'; +import { StepType } from '../src/types/step-definition'; jest.mock('../src/http/executor-http-server'); const MockedExecutorHttpServer = ExecutorHttpServer as jest.MockedClass; -function createRunnerConfig(overrides: { httpPort?: number } = {}) { +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const POLLING_INTERVAL_MS = 1000; + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +function createMockWorkflowPort(): jest.Mocked { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest.fn(), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function createMockAiClient() { + return { + getModel: jest.fn().mockReturnValue({} as BaseChatModel), + loadRemoteTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockResolvedValue(undefined), + }; +} + +function createMockLogger(): jest.Mocked { + return { error: jest.fn() }; +} + +function createRunnerConfig( + overrides: Partial<{ + workflowPort: WorkflowPort; + aiClient: AiClient; + logger: Logger; + httpPort: number; + }> = {}, +) { return { agentPort: {} as AgentPort, - workflowPort: {} as WorkflowPort, + workflowPort: createMockWorkflowPort(), runStore: {} as RunStore, - pollingIntervalMs: 2000, + pollingIntervalMs: POLLING_INTERVAL_MS, + aiClient: createMockAiClient() as unknown as AiClient, + logger: createMockLogger(), ...overrides, }; } -describe('Runner', () => { - beforeEach(() => { - jest.clearAllMocks(); - MockedExecutorHttpServer.prototype.start = jest.fn().mockResolvedValue(undefined); - MockedExecutorHttpServer.prototype.stop = jest.fn().mockResolvedValue(undefined); +function makeStepDefinition(stepType: StepType): StepDefinition { + if (stepType === StepType.Condition) { + return { type: StepType.Condition, options: ['opt1', 'opt2'] }; + } + + if (stepType === StepType.McpTask) { + return { type: StepType.McpTask }; + } + + return { type: stepType as Exclude }; +} + +function makePendingStep( + overrides: Partial & { stepType?: StepType } = {}, +): PendingStepExecution { + const { stepType = StepType.ReadRecord, ...rest } = overrides; + + return { + runId: 'run-1', + stepId: 'step-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, + stepDefinition: makeStepDefinition(stepType), + previousSteps: [], + ...rest, + }; +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +let executeSpy: jest.SpyInstance; +let runner: Runner; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + + MockedExecutorHttpServer.prototype.start = jest.fn().mockResolvedValue(undefined); + MockedExecutorHttpServer.prototype.stop = jest.fn().mockResolvedValue(undefined); + + executeSpy = jest.spyOn(BaseStepExecutor.prototype, 'execute').mockResolvedValue({ + stepOutcome: { type: 'record-task', stepId: 'step-1', stepIndex: 0, status: 'success' }, }); +}); - describe('start', () => { - it('should start the HTTP server when httpPort is configured', async () => { - const config = createRunnerConfig({ httpPort: 3100 }); - const runner = new Runner(config); +afterEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (runner) { + await runner.stop(); + (runner as Runner | undefined) = undefined; + } - await runner.start(); + jest.clearAllTimers(); +}); - expect(MockedExecutorHttpServer).toHaveBeenCalledWith({ - port: 3100, - runStore: config.runStore, - runner, - }); - expect(MockedExecutorHttpServer.prototype.start).toHaveBeenCalled(); - }); +// --------------------------------------------------------------------------- +// HTTP server (existing tests, kept passing) +// --------------------------------------------------------------------------- - it('should not start the HTTP server when httpPort is not configured', async () => { - const runner = new Runner(createRunnerConfig()); +describe('start', () => { + it('should start the HTTP server when httpPort is configured', async () => { + const config = createRunnerConfig({ httpPort: 3100 }); + runner = new Runner(config); - await runner.start(); + await runner.start(); - expect(MockedExecutorHttpServer).not.toHaveBeenCalled(); + expect(MockedExecutorHttpServer).toHaveBeenCalledWith({ + port: 3100, + runStore: config.runStore, + runner, }); + expect(MockedExecutorHttpServer.prototype.start).toHaveBeenCalled(); + }); - it('should not create a second HTTP server if already started', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + it('should not start the HTTP server when httpPort is not configured', async () => { + runner = new Runner(createRunnerConfig()); - await runner.start(); - await runner.start(); + await runner.start(); - expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(1); - }); + expect(MockedExecutorHttpServer).not.toHaveBeenCalled(); + }); + + it('should not create a second HTTP server if already started', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.start(); + + expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(1); + }); +}); + +describe('stop', () => { + it('should stop the HTTP server when running', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.stop(); + + expect(MockedExecutorHttpServer.prototype.stop).toHaveBeenCalled(); + }); + + it('should handle stop when no HTTP server is running', async () => { + runner = new Runner(createRunnerConfig()); + + await expect(runner.stop()).resolves.toBeUndefined(); + }); + + it('should allow restarting after stop', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.stop(); + await runner.start(); + + expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(2); }); +}); + +// --------------------------------------------------------------------------- +// Polling loop +// --------------------------------------------------------------------------- + +describe('polling loop', () => { + it('schedules a poll after pollingIntervalMs', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); + + it('reschedules automatically after each cycle', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); + }); + + it('stop() prevents scheduling a new cycle', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + + await runner.stop(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS * 3); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); + + it('stop() clears the pending timer', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + await runner.stop(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + }); + + it('calling start() twice does not schedule two timers', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + + await runner.start(); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Deduplication +// --------------------------------------------------------------------------- + +describe('deduplication', () => { + it('skips a step whose key is already in inFlightSteps', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'inflight-step' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); - describe('stop', () => { - it('should stop the HTTP server when running', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + // Block the first execution so the key stays in-flight + const unblockRef = { fn: (): void => {} }; + executeSpy.mockReturnValueOnce( + new Promise(resolve => { + unblockRef.fn = () => + resolve({ + stepOutcome: { + type: 'record-task', + stepId: 'inflight-step', + stepIndex: 0, + status: 'success', + }, + }); + }), + ); - await runner.start(); - await runner.stop(); + runner = new Runner(createRunnerConfig({ workflowPort })); - expect(MockedExecutorHttpServer.prototype.stop).toHaveBeenCalled(); + const poll1 = runner.triggerPoll('run-1'); + await Promise.resolve(); // let getPendingStepExecutions resolve and step key get added + + // Second poll: step is in-flight → should be skipped + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + + unblockRef.fn(); + await poll1; + }); + + it('removes the step key after successful execution', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-dedup' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await runner.triggerPoll('run-1'); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(2); + }); + + it('removes the step key even if execute throws', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-throws' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + executeSpy.mockRejectedValueOnce(new Error('execution error')); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await runner.triggerPoll('run-1'); + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// triggerPoll +// --------------------------------------------------------------------------- + +describe('triggerPoll', () => { + it('executes only steps for the given runId', async () => { + const workflowPort = createMockWorkflowPort(); + const stepA = makePendingStep({ runId: 'run-A', stepId: 'step-a' }); + const stepB = makePendingStep({ runId: 'run-B', stepId: 'step-b' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([stepA, stepB]); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-A'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + // The one execution was for run-A + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-A', expect.anything()); + expect(workflowPort.updateStepExecution).not.toHaveBeenCalledWith('run-B', expect.anything()); + }); + + it('skips in-flight steps', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-inflight' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + + const unblockRef = { fn: (): void => {} }; + executeSpy.mockReturnValueOnce( + new Promise(resolve => { + unblockRef.fn = () => + resolve({ + stepOutcome: { + type: 'record-task', + stepId: 'step-inflight', + stepIndex: 0, + status: 'success', + }, + }); + }), + ); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + const poll1 = runner.triggerPoll('run-1'); + await Promise.resolve(); + + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + + unblockRef.fn(); + await poll1; + }); + + it('resolves after all matching steps have settled', async () => { + const workflowPort = createMockWorkflowPort(); + const steps = [ + makePendingStep({ runId: 'run-1', stepId: 'step-a' }), + makePendingStep({ runId: 'run-1', stepId: 'step-b' }), + ]; + workflowPort.getPendingStepExecutions.mockResolvedValue(steps); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); + expect(executeSpy).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// MCP lazy loading +// --------------------------------------------------------------------------- + +describe('MCP lazy loading (via once thunk)', () => { + it('does not call fetchRemoteTools when there are no McpTask steps', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepType: StepType.ReadRecord }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.getMcpServerConfigs).not.toHaveBeenCalled(); + expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); + }); + + it('calls fetchRemoteTools at most once even with multiple McpTask steps', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const steps = [ + makePendingStep({ runId: 'run-1', stepId: 'step-mcp-1', stepType: StepType.McpTask }), + makePendingStep({ runId: 'run-1', stepId: 'step-mcp-2', stepType: StepType.McpTask }), + ]; + workflowPort.getPendingStepExecutions.mockResolvedValue(steps); + // Provide a non-empty config so fetchRemoteTools actually calls loadRemoteTools + workflowPort.getMcpServerConfigs.mockResolvedValue([{ configs: {} }] as never); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.getMcpServerConfigs).toHaveBeenCalledTimes(1); + expect(aiClient.loadRemoteTools).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// getExecutor — factory +// --------------------------------------------------------------------------- + +function makeCtxStepDef(type: StepType) { + if (type === StepType.Condition) { + return { type: StepType.Condition as const, options: ['opt1'] as [string, ...string[]] }; + } + + if (type === StepType.McpTask) { + return { type: StepType.McpTask as const }; + } + + return { type: type as Exclude }; +} + +describe('getExecutor — factory', () => { + const makeCtx = (type: StepType) => ({ + runId: 'run-1', + stepId: 'step-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, + stepDefinition: makeCtxStepDef(type), + model: {} as BaseChatModel, + agentPort: {} as AgentPort, + workflowPort: {} as WorkflowPort, + runStore: {} as RunStore, + previousSteps: [] as [], + logger: { error: jest.fn() }, + }); + + it('dispatches Condition steps to ConditionStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.Condition }); + const ctx = makeCtx(StepType.Condition); + const executor = await getExecutor(step, ctx, jest.fn()); + expect(executor).toBeInstanceOf(ConditionStepExecutor); + }); + + it('dispatches ReadRecord steps to ReadRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.ReadRecord }); + const ctx = makeCtx(StepType.ReadRecord); + const executor = await getExecutor(step, ctx, jest.fn()); + expect(executor).toBeInstanceOf(ReadRecordStepExecutor); + }); + + it('dispatches UpdateRecord steps to UpdateRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.UpdateRecord }); + const ctx = makeCtx(StepType.UpdateRecord); + const executor = await getExecutor(step, ctx, jest.fn()); + expect(executor).toBeInstanceOf(UpdateRecordStepExecutor); + }); + + it('dispatches TriggerAction steps to TriggerRecordActionStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.TriggerAction }); + const ctx = makeCtx(StepType.TriggerAction); + const executor = await getExecutor(step, ctx, jest.fn()); + expect(executor).toBeInstanceOf(TriggerRecordActionStepExecutor); + }); + + it('dispatches LoadRelatedRecord steps to LoadRelatedRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.LoadRelatedRecord }); + const ctx = makeCtx(StepType.LoadRelatedRecord); + const executor = await getExecutor(step, ctx, jest.fn()); + expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); + }); + + it('dispatches McpTask steps to McpTaskStepExecutor and calls loadTools', async () => { + const step = makePendingStep({ stepType: StepType.McpTask }); + const ctx = makeCtx(StepType.McpTask); + const loadTools = jest.fn().mockResolvedValue([]); + const executor = await getExecutor(step, ctx, loadTools); + expect(executor).toBeInstanceOf(McpTaskStepExecutor); + expect(loadTools).toHaveBeenCalledTimes(1); + }); + + it('throws for an unknown step type', async () => { + const step = { + ...makePendingStep(), + stepDefinition: { type: 'unknown-type' as StepType }, + } as unknown as PendingStepExecution; + const ctx = makeCtx(StepType.ReadRecord); + await expect(getExecutor(step, ctx, jest.fn())).rejects.toThrow( + 'Unknown step type: unknown-type', + ); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('error handling', () => { + it('reports a fallback error outcome when buildContext throws (getModel throws)', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-err' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('AI not configured'); }); - it('should handle stop when no HTTP server is running', async () => { - const runner = new Runner(createRunnerConfig()); + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiClient: aiClient as unknown as AiClient, + logger: mockLogger, + }), + ); + await runner.triggerPoll('run-1'); - await expect(runner.stop()).resolves.toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-err', + stepIndex: 0, + error: 'AI not configured', + }), + ); + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-1', { + type: 'record-task', + stepId: 'step-err', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred.', }); + }); - it('should allow restarting after stop', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + it('logs unexpected errors with runId, stepId, and stack', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const error = new Error('something blew up'); + const step = makePendingStep({ runId: 'run-2', stepId: 'step-log' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + executeSpy.mockRejectedValueOnce(error); - await runner.start(); - await runner.stop(); - await runner.start(); + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-2'); - expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(2); - }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + runId: 'run-2', + stepId: 'step-log', + stepIndex: 0, + error: 'something blew up', + stack: expect.any(String), + }), + ); }); - describe('triggerPoll', () => { - it('should resolve without error', async () => { - const runner = new Runner(createRunnerConfig()); + it('does not re-throw if updateStepExecution fails in the fallback path', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-fallback' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + executeSpy.mockRejectedValueOnce(new Error('exec error')); + workflowPort.updateStepExecution.mockRejectedValueOnce(new Error('update failed')); - await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); - }); + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); + }); + + it('catches getPendingStepExecutions failure, logs it, and reschedules', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + workflowPort.getPendingStepExecutions + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValue([]); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Poll cycle failed', + expect.objectContaining({ error: 'network error' }), + ); + + // After the error, the cycle should have been rescheduled + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); }); }); From 6a76a2dca8ea9c8c6ee1f4923bcbfb6350b5ba63 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 15:33:48 +0100 Subject: [PATCH 25/34] feat(workflow-executor): add formattedResponse after MCP tool execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a MCP tool runs, trigger a second AI call that produces a concise human-readable summary stored as `formattedResponse` in `McpTaskStepExecutionData.executionResult`. The raw result is persisted first (safe state); the formatting step is non-blocking — failures are logged but never fail the step. Results longer than 20 000 chars are truncated before injection. `StepExecutionFormatters` now handles `mcp-task`: returns `Result: ` when available, or a generic fallback line to avoid exposing raw `toolResult` in subsequent AI context messages. Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/mcp-task-step-executor.ts | 73 +++++++++-- .../summary/step-execution-formatters.ts | 17 +++ .../src/types/step-execution-data.ts | 4 +- .../src/types/step-outcome.ts | 2 +- .../executors/mcp-task-step-executor.test.ts | 124 +++++++++++++++++- .../step-execution-formatters.test.ts | 50 +++++++ 6 files changed, 259 insertions(+), 11 deletions(-) diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts index 4180f7117c..8a5560e1ba 100644 --- a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -3,7 +3,8 @@ import type { McpTaskStepDefinition } from '../types/step-definition'; import type { McpTaskStepExecutionData, McpToolCall } from '../types/step-execution-data'; import type { RemoteTool } from '@forestadmin/ai-proxy'; -import { HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; import { McpToolInvocationError, @@ -83,14 +84,18 @@ export default class McpTaskStepExecutor extends RecordTaskStepExecutor { + if (toolResult === null || toolResult === undefined) return null; + + const resultStr = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); + const truncatedResult = + resultStr.length > 20_000 ? `${resultStr.slice(0, 20_000)}\n... [truncated]` : resultStr; + + const summaryTool = new DynamicStructuredTool({ + name: 'summarize-result', + description: 'Provides a human-readable summary of the tool execution result.', + schema: z.object({ + summary: z.string().min(1).describe('Concise human-readable summary of the tool result.'), + }), + func: undefined, + }); + + const messages = [ + new SystemMessage( + 'You are summarizing the result of a workflow tool execution for the end user. ' + + 'Be concise and factual. Do not include raw JSON or technical identifiers.', + ), + new HumanMessage( + `Tool "${tool.name}" was executed with input: ${JSON.stringify(tool.input)}.\n` + + `Result: ${truncatedResult}\n\n` + + `Provide a concise human-readable summary.`, + ), + ]; + + const { summary } = await this.invokeWithTool<{ summary: string }>(messages, summaryTool); + + return summary || null; + } + private async selectTool(tools: RemoteTool[]) { const messages = [ ...(await this.buildPreviousStepsMessages()), diff --git a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts index 8a86035528..55fdda59a2 100644 --- a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts +++ b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts @@ -1,5 +1,6 @@ import type { LoadRelatedRecordStepExecutionData, + McpTaskStepExecutionData, StepExecutionData, } from '../../types/step-execution-data'; @@ -20,11 +21,27 @@ export default class StepExecutionFormatters { switch (execution.type) { case 'load-related-record': return StepExecutionFormatters.formatLoadRelatedRecord(execution); + case 'mcp-task': + return StepExecutionFormatters.formatMcpTask(execution as McpTaskStepExecutionData); default: return null; } } + private static formatMcpTask(execution: McpTaskStepExecutionData): string | null { + const { executionResult } = execution; + if (!executionResult) return null; + if ('skipped' in executionResult) return null; + + if (executionResult.formattedResponse) { + return ` Result: ${executionResult.formattedResponse}`; + } + + const toolName = execution.executionParams?.name ?? 'unknown tool'; + + return ` Executed: ${toolName} (result not summarized)`; + } + private static formatLoadRelatedRecord( execution: LoadRelatedRecordStepExecutionData, ): string | null { diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index d71190b450..45fa00b76d 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -93,7 +93,9 @@ export interface McpToolCall extends McpToolRef { export interface McpTaskStepExecutionData extends BaseStepExecutionData { type: 'mcp-task'; executionParams?: McpToolCall; - executionResult?: { success: true; toolResult: unknown } | { skipped: true }; + executionResult?: + | { success: true; toolResult: unknown; formattedResponse?: string } + | { skipped: true }; pendingData?: McpToolCall; } diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index b5df5ac9ec..38b4206105 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -5,7 +5,7 @@ export type BaseStepStatus = 'success' | 'error'; /** Condition steps can fall back to human decision when the AI is uncertain. */ export type ConditionStepStatus = BaseStepStatus | 'manual-decision'; -/** AI task steps can pause mid-execution to await user input (e.g. tool confirmation). */ +/** AI task steps can pause mid-execution to await user input (e.g. awaiting-input). */ export type RecordTaskStepStatus = BaseStepStatus | 'awaiting-input'; /** Union of all step statuses. */ diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts index d1502e65a6..128e455fd4 100644 --- a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -132,8 +132,130 @@ describe('McpTaskStepExecutor', () => { executionResult: { success: true, toolResult: { result: 'notification sent' } }, }), ); - // Verify the model was bound with the tool's base interface + // Model is invoked twice: once for tool selection, once for AI formatting + expect(modelInvoke).toHaveBeenCalledTimes(2); + }); + + it('persists formattedResponse when AI formatting succeeds', async () => { + const toolResult = { result: 'notification sent' }; + const invokeFn = jest.fn().mockResolvedValue(toolResult); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { + message: 'Hello', + }); + // Second model call (formatting) returns a summary + modelInvoke + .mockResolvedValueOnce({ + tool_calls: [{ name: 'send_notification', args: { message: 'Hello' }, id: 'call_1' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'summarize-result', args: { summary: 'Found 3 results.' }, id: 'call_2' }, + ], + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(modelInvoke).toHaveBeenCalledTimes(2); + // First save: raw result only + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 1, + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult }, + }), + ); + // Second save: raw result + formattedResponse + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 2, + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult, formattedResponse: 'Found 3 results.' }, + }), + ); + }); + + it('returns success and logs when AI formatting throws', async () => { + const invokeFn = jest.fn().mockResolvedValue({ result: 'ok' }); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { message: 'Hi' }); + // Second call (formatting) returns no tool calls → MissingToolCallError + modelInvoke + .mockResolvedValueOnce({ + tool_calls: [{ name: 'send_notification', args: { message: 'Hi' }, id: 'call_1' }], + }) + .mockResolvedValueOnce({ tool_calls: [] }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Only the first save (raw result) — no second save since formatting failed + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult: { result: 'ok' } }, + }), + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to format MCP tool result, using generic fallback', + expect.objectContaining({ toolName: 'send_notification' }), + ); + }); + + it('does not call AI formatting when toolResult is null', async () => { + const invokeFn = jest.fn().mockResolvedValue(null); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { message: 'Hi' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Model called only once (tool selection) — no formatting call for null result expect(modelInvoke).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult: null }, + }), + ); }); }); diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts index 82f3f76fc2..52bcbce4b9 100644 --- a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -65,6 +65,56 @@ describe('StepExecutionFormatters', () => { }); }); + describe('mcp-task', () => { + it('returns the Result: line when formattedResponse is present', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionParams: { name: 'search_records', input: { query: 'foo' } }, + executionResult: { + success: true, + toolResult: { items: [] }, + formattedResponse: 'No records found.', + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe(' Result: No records found.'); + }); + + it('returns a generic Executed: line when formattedResponse is absent', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionParams: { name: 'search_records', input: { query: 'foo' } }, + executionResult: { success: true, toolResult: { items: [] } }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Executed: search_records (result not summarized)', + ); + }); + + it('returns null when executionResult is absent (pending phase)', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + pendingData: { name: 'search_records', input: {} }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null for a skipped execution', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionResult: { skipped: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + }); + describe('types without a custom formatter', () => { it('returns null for condition type', () => { const execution: StepExecutionData = { From 062d12d063573c1c35337108cf4cd75cd3058850 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 16:16:20 +0100 Subject: [PATCH 26/34] refactor(workflow-executor): add McpTaskStepOutcome and decouple McpTaskStepExecutor Move handleConfirmationFlow() up to BaseStepExecutor so it is reusable by any executor. McpTaskStepExecutor now extends BaseStepExecutor directly instead of RecordTaskStepExecutor, and emits its own type 'mcp-task' outcome. Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/base-step-executor.ts | 40 ++++++++++++++++++ .../src/executors/mcp-task-step-executor.ts | 19 ++++++++- .../executors/record-task-step-executor.ts | 41 ------------------- packages/workflow-executor/src/index.ts | 1 + packages/workflow-executor/src/runner.ts | 14 +++++-- .../src/types/step-outcome.ts | 7 +++- .../executors/mcp-task-step-executor.test.ts | 2 +- .../workflow-executor/test/runner.test.ts | 24 +++++++++++ 8 files changed, 99 insertions(+), 49 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index b09a7a8e3a..c2c5c3fbc1 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -2,6 +2,7 @@ import type { AgentPort } from '../ports/agent-port'; import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; +import type { StepExecutionData } from '../types/step-execution-data'; import type { BaseStepStatus } from '../types/step-outcome'; import type { BaseMessage, StructuredToolInterface } from '@forestadmin/ai-proxy'; @@ -13,11 +14,14 @@ import { MalformedToolCallError, MissingToolCallError, NoRecordsError, + StepStateError, WorkflowExecutorError, } from '../errors'; import SafeAgentPort from './safe-agent-port'; import StepSummaryBuilder from './summary/step-summary-builder'; +type WithPendingData = StepExecutionData & { pendingData?: object }; + export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; @@ -81,6 +85,42 @@ export default abstract class BaseStepExecutor( + typeDiscriminator: string, + resolveAndExecute: (execution: TExec) => Promise, + ): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = stepExecutions.find( + (e): e is TExec => + (e as TExec).type === typeDiscriminator && e.stepIndex === this.context.stepIndex, + ); + + if (!execution) { + throw new StepStateError( + `No execution record found for step at index ${this.context.stepIndex}`, + ); + } + + if (!execution.pendingData) { + throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); + } + + if (!this.context.userConfirmed) { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...execution, + executionResult: { skipped: true }, + } as StepExecutionData); + + return this.buildOutcomeResult({ status: 'success' }); + } + + return resolveAndExecute(execution); + } + /** * Returns a SystemMessage array summarizing previously executed steps. * Empty array when there is no history. Ready to spread into a messages array. diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts index 8a5560e1ba..fe9c84e9c2 100644 --- a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -1,6 +1,7 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { McpTaskStepDefinition } from '../types/step-definition'; import type { McpTaskStepExecutionData, McpToolCall } from '../types/step-execution-data'; +import type { RecordTaskStepStatus } from '../types/step-outcome'; import type { RemoteTool } from '@forestadmin/ai-proxy'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; @@ -12,7 +13,7 @@ import { NoMcpToolsError, StepPersistenceError, } from '../errors'; -import RecordTaskStepExecutor from './record-task-step-executor'; +import BaseStepExecutor from './base-step-executor'; 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. @@ -21,7 +22,7 @@ Important rules: - Select only the tool directly relevant to the request. - Final answer is definitive, you won't receive any other input from the user.`; -export default class McpTaskStepExecutor extends RecordTaskStepExecutor { +export default class McpTaskStepExecutor extends BaseStepExecutor { private readonly remoteTools: readonly RemoteTool[]; constructor( @@ -32,6 +33,20 @@ export default class McpTaskStepExecutor extends RecordTaskStepExecutor { if (this.context.userConfirmed !== undefined) { // Branch A -- Re-entry with user confirmation diff --git a/packages/workflow-executor/src/executors/record-task-step-executor.ts b/packages/workflow-executor/src/executors/record-task-step-executor.ts index f0067cae73..86b8841092 100644 --- a/packages/workflow-executor/src/executors/record-task-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-task-step-executor.ts @@ -1,14 +1,9 @@ import type { StepExecutionResult } from '../types/execution'; import type { StepDefinition } from '../types/step-definition'; -import type { StepExecutionData } from '../types/step-execution-data'; import type { RecordTaskStepStatus } from '../types/step-outcome'; -import { StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -/** Execution data that includes the fields required by the confirmation flow. */ -type WithPendingData = StepExecutionData & { pendingData?: object }; - export default abstract class RecordTaskStepExecutor< TStep extends StepDefinition = StepDefinition, > extends BaseStepExecutor { @@ -25,40 +20,4 @@ export default abstract class RecordTaskStepExecutor< }, }; } - - /** - * Shared confirmation flow for executors that require user approval before acting. - * Handles the find → guard → skipped → delegate pattern. - */ - protected async handleConfirmationFlow( - typeDiscriminator: string, - resolveAndExecute: (execution: TExec) => Promise, - ): Promise { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const execution = stepExecutions.find( - (e): e is TExec => - (e as TExec).type === typeDiscriminator && e.stepIndex === this.context.stepIndex, - ); - - if (!execution) { - throw new StepStateError( - `No execution record found for step at index ${this.context.stepIndex}`, - ); - } - - if (!execution.pendingData) { - throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); - } - - if (!this.context.userConfirmed) { - await this.context.runStore.saveStepExecution(this.context.runId, { - ...execution, - executionResult: { skipped: true }, - } as StepExecutionData); - - return this.buildOutcomeResult({ status: 'success' }); - } - - return resolveAndExecute(execution); - } } diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index d323d450ac..3075c5f418 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -10,6 +10,7 @@ export type { StepStatus, ConditionStepOutcome, RecordTaskStepOutcome, + McpTaskStepOutcome, StepOutcome, } from './types/step-outcome'; diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 49db62f0f6..5ebb8c4862 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -13,6 +13,7 @@ import type { StepOutcome } from './types/step-outcome'; import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; +import { StepStateError } from './errors'; import ConditionStepExecutor from './executors/condition-step-executor'; import LoadRelatedRecordStepExecutor from './executors/load-related-record-step-executor'; import McpTaskStepExecutor from './executors/mcp-task-step-executor'; @@ -46,8 +47,11 @@ function stepKey(step: PendingStepExecution): string { return `${step.runId}:${step.stepId}`; } -function stepOutcomeType(step: PendingStepExecution): 'condition' | 'record-task' { - return step.stepDefinition.type === StepType.Condition ? 'condition' : 'record-task'; +function stepOutcomeType(step: PendingStepExecution): 'condition' | 'record-task' | 'mcp-task' { + if (step.stepDefinition.type === StepType.Condition) return 'condition'; + if (step.stepDefinition.type === StepType.McpTask) return 'mcp-task'; + + return 'record-task'; } function causeMessage(error: unknown): string | undefined { @@ -82,7 +86,9 @@ export async function getExecutor( await loadTools(), ); default: - throw new Error(`Unknown step type: ${(step.stepDefinition as { type: string }).type}`); + throw new StepStateError( + `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, + ); } } @@ -207,7 +213,7 @@ export default class Runner { stepIndex: step.stepIndex, status: 'error', error: 'An unexpected error occurred.', - }; + } as StepOutcome; } finally { this.inFlightSteps.delete(key); } diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index 38b4206105..b1fbe2d467 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -35,4 +35,9 @@ export interface RecordTaskStepOutcome extends BaseStepOutcome { status: RecordTaskStepStatus; } -export type StepOutcome = ConditionStepOutcome | RecordTaskStepOutcome; +export interface McpTaskStepOutcome extends BaseStepOutcome { + type: 'mcp-task'; + status: RecordTaskStepStatus; +} + +export type StepOutcome = ConditionStepOutcome | RecordTaskStepOutcome | McpTaskStepOutcome; diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts index 128e455fd4..e487906aab 100644 --- a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -524,7 +524,7 @@ describe('McpTaskStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome).toMatchObject({ - type: 'record-task', + type: 'mcp-task', stepId: 'mcp-1', stepIndex: 0, status: 'success', diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index d6f1bcef0c..4a474a99f7 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -584,6 +584,30 @@ describe('error handling', () => { }); }); + it('reports type mcp-task in fallback error outcome for McpTask steps', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-err', + stepType: StepType.McpTask, + }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('AI not configured'); + }); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ type: 'mcp-task', status: 'error' }), + ); + }); + it('logs unexpected errors with runId, stepId, and stack', async () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); From 0eceffbed08a6f9efec041c91370cf38bf582372 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 16:19:20 +0100 Subject: [PATCH 27/34] refactor(workflow-executor): move module-level helpers to private static methods on Runner once, stepKey, stepOutcomeType, causeMessage are now private static methods on the Runner class instead of loose module-level functions. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/runner.ts | 74 +++++++++++++----------- 1 file changed, 39 insertions(+), 35 deletions(-) diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 5ebb8c4862..26f0db3689 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -33,33 +33,6 @@ export interface RunnerConfig { httpPort?: number; } -function once(fn: () => Promise): () => Promise { - let cached: Promise | undefined; - - return () => { - cached ??= fn(); - - return cached; - }; -} - -function stepKey(step: PendingStepExecution): string { - return `${step.runId}:${step.stepId}`; -} - -function stepOutcomeType(step: PendingStepExecution): 'condition' | 'record-task' | 'mcp-task' { - if (step.stepDefinition.type === StepType.Condition) return 'condition'; - if (step.stepDefinition.type === StepType.McpTask) return 'mcp-task'; - - return 'record-task'; -} - -function causeMessage(error: unknown): string | undefined { - const { cause } = error as { cause?: unknown }; - - return cause instanceof Error ? cause.message : undefined; -} - export async function getExecutor( step: PendingStepExecution, context: ExecutionContext, @@ -100,6 +73,35 @@ export default class Runner { private isRunning = false; private readonly logger: Logger; + private static once(fn: () => Promise): () => Promise { + let cached: Promise | undefined; + + return () => { + cached ??= fn(); + + return cached; + }; + } + + private static stepKey(step: PendingStepExecution): string { + return `${step.runId}:${step.stepId}`; + } + + private static stepOutcomeType( + step: PendingStepExecution, + ): 'condition' | 'record-task' | 'mcp-task' { + if (step.stepDefinition.type === StepType.Condition) return 'condition'; + if (step.stepDefinition.type === StepType.McpTask) return 'mcp-task'; + + return 'record-task'; + } + + private static causeMessage(error: unknown): string | undefined { + const { cause } = error as { cause?: unknown }; + + return cause instanceof Error ? cause.message : undefined; + } + constructor(config: RunnerConfig) { this.config = config; this.logger = config.logger ?? new ConsoleLogger(); @@ -147,8 +149,10 @@ export default class Runner { async triggerPoll(runId: string): Promise { const steps = await this.config.workflowPort.getPendingStepExecutions(); - const pending = steps.filter(s => s.runId === runId && !this.inFlightSteps.has(stepKey(s))); - const loadTools = once(() => this.fetchRemoteTools()); + const pending = steps.filter( + s => s.runId === runId && !this.inFlightSteps.has(Runner.stepKey(s)), + ); + const loadTools = Runner.once(() => this.fetchRemoteTools()); await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); } @@ -160,8 +164,8 @@ export default class Runner { private async runPollCycle(): Promise { try { const steps = await this.config.workflowPort.getPendingStepExecutions(); - const pending = steps.filter(s => !this.inFlightSteps.has(stepKey(s))); - const loadTools = once(() => this.fetchRemoteTools()); + const pending = steps.filter(s => !this.inFlightSteps.has(Runner.stepKey(s))); + const loadTools = Runner.once(() => this.fetchRemoteTools()); await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); } catch (error) { this.logger.error('Poll cycle failed', { @@ -189,7 +193,7 @@ export default class Runner { step: PendingStepExecution, loadTools: () => Promise, ): Promise { - const key = stepKey(step); + const key = Runner.stepKey(step); this.inFlightSteps.add(key); let stepOutcome: StepOutcome; @@ -204,11 +208,11 @@ export default class Runner { stepId: step.stepId, stepIndex: step.stepIndex, error: error instanceof Error ? error.message : String(error), - cause: causeMessage(error), + cause: Runner.causeMessage(error), stack: error instanceof Error ? error.stack : undefined, }); stepOutcome = { - type: stepOutcomeType(step), + type: Runner.stepOutcomeType(step), stepId: step.stepId, stepIndex: step.stepIndex, status: 'error', @@ -226,7 +230,7 @@ export default class Runner { stepId: step.stepId, stepIndex: step.stepIndex, error: error instanceof Error ? error.message : String(error), - cause: causeMessage(error), + cause: Runner.causeMessage(error), stack: error instanceof Error ? error.stack : undefined, }); } From 163510231c6ac1edd3055eee5f0e2cfeb018d54b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 16:32:16 +0100 Subject: [PATCH 28/34] refactor(workflow-executor): extract getExecutor into StepExecutorFactory Move step dispatch logic from runner.ts into a dedicated StepExecutorFactory.create() static method in the executors/ folder, co-locating it with its peers. Runner is now solely responsible for orchestration; step type routing lives in the factory. Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/step-executor-factory.ts | 51 +++++++++++++++++++ packages/workflow-executor/src/runner.ts | 48 +---------------- .../workflow-executor/test/runner.test.ts | 19 +++---- 3 files changed, 63 insertions(+), 55 deletions(-) create mode 100644 packages/workflow-executor/src/executors/step-executor-factory.ts diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts new file mode 100644 index 0000000000..1095d9db56 --- /dev/null +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -0,0 +1,51 @@ +import type BaseStepExecutor from './base-step-executor'; +import type { ExecutionContext, PendingStepExecution } from '../types/execution'; +import type { + ConditionStepDefinition, + McpTaskStepDefinition, + RecordTaskStepDefinition, +} from '../types/step-definition'; +import type { RemoteTool } from '@forestadmin/ai-proxy'; + +import { StepStateError } from '../errors'; +import ConditionStepExecutor from './condition-step-executor'; +import LoadRelatedRecordStepExecutor from './load-related-record-step-executor'; +import McpTaskStepExecutor from './mcp-task-step-executor'; +import ReadRecordStepExecutor from './read-record-step-executor'; +import TriggerRecordActionStepExecutor from './trigger-record-action-step-executor'; +import UpdateRecordStepExecutor from './update-record-step-executor'; +import { StepType } from '../types/step-definition'; + +export default class StepExecutorFactory { + static async create( + step: PendingStepExecution, + context: ExecutionContext, + loadTools: () => Promise, + ): Promise { + switch (step.stepDefinition.type) { + case StepType.Condition: + return new ConditionStepExecutor(context as ExecutionContext); + case StepType.ReadRecord: + return new ReadRecordStepExecutor(context as ExecutionContext); + case StepType.UpdateRecord: + return new UpdateRecordStepExecutor(context as ExecutionContext); + case StepType.TriggerAction: + return new TriggerRecordActionStepExecutor( + context as ExecutionContext, + ); + case StepType.LoadRelatedRecord: + return new LoadRelatedRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.McpTask: + return new McpTaskStepExecutor( + context as ExecutionContext, + await loadTools(), + ); + default: + throw new StepStateError( + `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, + ); + } + } +} diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 26f0db3689..7dff911c83 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -1,25 +1,13 @@ -import type BaseStepExecutor from './executors/base-step-executor'; import type { AgentPort } from './ports/agent-port'; import type { Logger } from './ports/logger-port'; import type { RunStore } from './ports/run-store'; import type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; import type { ExecutionContext, PendingStepExecution } from './types/execution'; -import type { - ConditionStepDefinition, - McpTaskStepDefinition, - RecordTaskStepDefinition, -} from './types/step-definition'; import type { StepOutcome } from './types/step-outcome'; import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; -import { StepStateError } from './errors'; -import ConditionStepExecutor from './executors/condition-step-executor'; -import LoadRelatedRecordStepExecutor from './executors/load-related-record-step-executor'; -import McpTaskStepExecutor from './executors/mcp-task-step-executor'; -import ReadRecordStepExecutor from './executors/read-record-step-executor'; -import TriggerRecordActionStepExecutor from './executors/trigger-record-action-step-executor'; -import UpdateRecordStepExecutor from './executors/update-record-step-executor'; +import StepExecutorFactory from './executors/step-executor-factory'; import ExecutorHttpServer from './http/executor-http-server'; import { StepType } from './types/step-definition'; @@ -33,38 +21,6 @@ export interface RunnerConfig { httpPort?: number; } -export async function getExecutor( - step: PendingStepExecution, - context: ExecutionContext, - loadTools: () => Promise, -): Promise { - switch (step.stepDefinition.type) { - case StepType.Condition: - return new ConditionStepExecutor(context as ExecutionContext); - case StepType.ReadRecord: - return new ReadRecordStepExecutor(context as ExecutionContext); - case StepType.UpdateRecord: - return new UpdateRecordStepExecutor(context as ExecutionContext); - case StepType.TriggerAction: - return new TriggerRecordActionStepExecutor( - context as ExecutionContext, - ); - case StepType.LoadRelatedRecord: - return new LoadRelatedRecordStepExecutor( - context as ExecutionContext, - ); - case StepType.McpTask: - return new McpTaskStepExecutor( - context as ExecutionContext, - await loadTools(), - ); - default: - throw new StepStateError( - `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, - ); - } -} - export default class Runner { private readonly config: RunnerConfig; private httpServer: ExecutorHttpServer | null = null; @@ -200,7 +156,7 @@ export default class Runner { try { const context = this.buildContext(step); - const executor = await getExecutor(step, context, loadTools); + const executor = await StepExecutorFactory.create(step, context, loadTools); ({ stepOutcome } = await executor.execute()); } catch (error) { this.logger.error('Step execution failed unexpectedly', { diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 4a474a99f7..ec6e6ebf7e 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -11,10 +11,11 @@ import ConditionStepExecutor from '../src/executors/condition-step-executor'; import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; import McpTaskStepExecutor from '../src/executors/mcp-task-step-executor'; import ReadRecordStepExecutor from '../src/executors/read-record-step-executor'; +import StepExecutorFactory from '../src/executors/step-executor-factory'; import TriggerRecordActionStepExecutor from '../src/executors/trigger-record-action-step-executor'; import UpdateRecordStepExecutor from '../src/executors/update-record-step-executor'; import ExecutorHttpServer from '../src/http/executor-http-server'; -import Runner, { getExecutor } from '../src/runner'; +import Runner from '../src/runner'; import { StepType } from '../src/types/step-definition'; jest.mock('../src/http/executor-http-server'); @@ -471,7 +472,7 @@ function makeCtxStepDef(type: StepType) { return { type: type as Exclude }; } -describe('getExecutor — factory', () => { +describe('StepExecutorFactory.create — factory', () => { const makeCtx = (type: StepType) => ({ runId: 'run-1', stepId: 'step-1', @@ -489,35 +490,35 @@ describe('getExecutor — factory', () => { it('dispatches Condition steps to ConditionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.Condition }); const ctx = makeCtx(StepType.Condition); - const executor = await getExecutor(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); expect(executor).toBeInstanceOf(ConditionStepExecutor); }); it('dispatches ReadRecord steps to ReadRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.ReadRecord }); const ctx = makeCtx(StepType.ReadRecord); - const executor = await getExecutor(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); expect(executor).toBeInstanceOf(ReadRecordStepExecutor); }); it('dispatches UpdateRecord steps to UpdateRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.UpdateRecord }); const ctx = makeCtx(StepType.UpdateRecord); - const executor = await getExecutor(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); expect(executor).toBeInstanceOf(UpdateRecordStepExecutor); }); it('dispatches TriggerAction steps to TriggerRecordActionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.TriggerAction }); const ctx = makeCtx(StepType.TriggerAction); - const executor = await getExecutor(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); expect(executor).toBeInstanceOf(TriggerRecordActionStepExecutor); }); it('dispatches LoadRelatedRecord steps to LoadRelatedRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.LoadRelatedRecord }); const ctx = makeCtx(StepType.LoadRelatedRecord); - const executor = await getExecutor(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); }); @@ -525,7 +526,7 @@ describe('getExecutor — factory', () => { const step = makePendingStep({ stepType: StepType.McpTask }); const ctx = makeCtx(StepType.McpTask); const loadTools = jest.fn().mockResolvedValue([]); - const executor = await getExecutor(step, ctx, loadTools); + const executor = await StepExecutorFactory.create(step, ctx, loadTools); expect(executor).toBeInstanceOf(McpTaskStepExecutor); expect(loadTools).toHaveBeenCalledTimes(1); }); @@ -536,7 +537,7 @@ describe('getExecutor — factory', () => { stepDefinition: { type: 'unknown-type' as StepType }, } as unknown as PendingStepExecution; const ctx = makeCtx(StepType.ReadRecord); - await expect(getExecutor(step, ctx, jest.fn())).rejects.toThrow( + await expect(StepExecutorFactory.create(step, ctx, jest.fn())).rejects.toThrow( 'Unknown step type: unknown-type', ); }); From 489243738e56d4ffb12cc4d4ce54c9c82f2fc0f3 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 17:17:37 +0100 Subject: [PATCH 29/34] refactor(workflow-executor): never-throw executor contract + ErrorStepExecutor - Add IStepExecutor interface to execution.ts - Add stepTypeToOutcomeType() helper to step-outcome.ts - Add ErrorStepExecutor: catches construction errors, returns typed error outcome - StepExecutorFactory absorbs buildContext, takes StepContextConfig, never throws - Runner simplified: removes buildContext, stepOutcomeType, StepOutcome knowledge - Extract causeMessage() to errors.ts (shared utility, removes DRY violation) - BaseStepExecutor explicitly implements IStepExecutor - Add error-step-executor.test.ts with full coverage (contract, logging, type mapping) - Strengthen runner.test.ts: FATAL path, loadTools rejection, non-Error throwable, stepTypeToOutcomeType unit tests, deduplication assertion Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/errors.ts | 6 + .../src/executors/base-step-executor.ts | 6 +- .../src/executors/error-step-executor.ts | 34 +++ .../src/executors/step-executor-factory.ts | 92 ++++--- packages/workflow-executor/src/runner.ts | 53 ++-- .../workflow-executor/src/types/execution.ts | 4 + .../src/types/step-outcome.ts | 9 + .../executors/error-step-executor.test.ts | 245 ++++++++++++++++++ .../workflow-executor/test/runner.test.ts | 158 +++++++---- 9 files changed, 491 insertions(+), 116 deletions(-) create mode 100644 packages/workflow-executor/src/executors/error-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/error-step-executor.test.ts diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 638d7c4e47..bd2548b695 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -1,5 +1,11 @@ /* eslint-disable max-classes-per-file */ +export function causeMessage(error: unknown): string | undefined { + const { cause } = error as { cause?: unknown }; + + return cause instanceof Error ? cause.message : undefined; +} + export abstract class WorkflowExecutorError extends Error { readonly userMessage: string; cause?: unknown; diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index c2c5c3fbc1..4472ff6ba0 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,5 +1,5 @@ import type { AgentPort } from '../ports/agent-port'; -import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { ExecutionContext, IStepExecutor, StepExecutionResult } from '../types/execution'; import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { StepExecutionData } from '../types/step-execution-data'; @@ -22,7 +22,9 @@ import StepSummaryBuilder from './summary/step-summary-builder'; type WithPendingData = StepExecutionData & { pendingData?: object }; -export default abstract class BaseStepExecutor { +export default abstract class BaseStepExecutor + implements IStepExecutor +{ protected readonly context: ExecutionContext; protected readonly agentPort: AgentPort; diff --git a/packages/workflow-executor/src/executors/error-step-executor.ts b/packages/workflow-executor/src/executors/error-step-executor.ts new file mode 100644 index 0000000000..a28251b6d5 --- /dev/null +++ b/packages/workflow-executor/src/executors/error-step-executor.ts @@ -0,0 +1,34 @@ +import type { Logger } from '../ports/logger-port'; +import type { IStepExecutor, PendingStepExecution, StepExecutionResult } from '../types/execution'; + +import { causeMessage } from '../errors'; +import { stepTypeToOutcomeType } from '../types/step-outcome'; + +export default class ErrorStepExecutor implements IStepExecutor { + constructor( + private readonly step: PendingStepExecution, + private readonly error: unknown, + private readonly logger: Logger, + ) {} + + async execute(): Promise { + this.logger.error('Step execution failed unexpectedly', { + runId: this.step.runId, + stepId: this.step.stepId, + stepIndex: this.step.stepIndex, + error: this.error instanceof Error ? this.error.message : String(this.error), + cause: causeMessage(this.error), + stack: this.error instanceof Error ? this.error.stack : undefined, + }); + + return { + stepOutcome: { + type: stepTypeToOutcomeType(this.step.stepDefinition.type), + stepId: this.step.stepId, + stepIndex: this.step.stepIndex, + status: 'error', + error: 'An unexpected error occurred.', + }, + }; + } +} diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 1095d9db56..518555db1f 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -1,14 +1,18 @@ -import type BaseStepExecutor from './base-step-executor'; -import type { ExecutionContext, PendingStepExecution } from '../types/execution'; +import type { AgentPort } from '../ports/agent-port'; +import type { Logger } from '../ports/logger-port'; +import type { RunStore } from '../ports/run-store'; +import type { WorkflowPort } from '../ports/workflow-port'; +import type { ExecutionContext, IStepExecutor, PendingStepExecution } from '../types/execution'; import type { ConditionStepDefinition, McpTaskStepDefinition, RecordTaskStepDefinition, } from '../types/step-definition'; -import type { RemoteTool } from '@forestadmin/ai-proxy'; +import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; import { StepStateError } from '../errors'; import ConditionStepExecutor from './condition-step-executor'; +import ErrorStepExecutor from './error-step-executor'; import LoadRelatedRecordStepExecutor from './load-related-record-step-executor'; import McpTaskStepExecutor from './mcp-task-step-executor'; import ReadRecordStepExecutor from './read-record-step-executor'; @@ -16,36 +20,66 @@ import TriggerRecordActionStepExecutor from './trigger-record-action-step-execut import UpdateRecordStepExecutor from './update-record-step-executor'; import { StepType } from '../types/step-definition'; +export interface StepContextConfig { + aiClient: AiClient; + agentPort: AgentPort; + workflowPort: WorkflowPort; + runStore: RunStore; + logger: Logger; +} + export default class StepExecutorFactory { static async create( step: PendingStepExecution, - context: ExecutionContext, + contextConfig: StepContextConfig, loadTools: () => Promise, - ): Promise { - switch (step.stepDefinition.type) { - case StepType.Condition: - return new ConditionStepExecutor(context as ExecutionContext); - case StepType.ReadRecord: - return new ReadRecordStepExecutor(context as ExecutionContext); - case StepType.UpdateRecord: - return new UpdateRecordStepExecutor(context as ExecutionContext); - case StepType.TriggerAction: - return new TriggerRecordActionStepExecutor( - context as ExecutionContext, - ); - case StepType.LoadRelatedRecord: - return new LoadRelatedRecordStepExecutor( - context as ExecutionContext, - ); - case StepType.McpTask: - return new McpTaskStepExecutor( - context as ExecutionContext, - await loadTools(), - ); - default: - throw new StepStateError( - `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, - ); + ): Promise { + try { + const context = StepExecutorFactory.buildContext(step, contextConfig); + + switch (step.stepDefinition.type) { + case StepType.Condition: + return new ConditionStepExecutor(context as ExecutionContext); + case StepType.ReadRecord: + return new ReadRecordStepExecutor(context as ExecutionContext); + case StepType.UpdateRecord: + return new UpdateRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.TriggerAction: + return new TriggerRecordActionStepExecutor( + context as ExecutionContext, + ); + case StepType.LoadRelatedRecord: + return new LoadRelatedRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.McpTask: + return new McpTaskStepExecutor( + context as ExecutionContext, + await loadTools(), + ); + default: + throw new StepStateError( + `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, + ); + } + } catch (error) { + return new ErrorStepExecutor(step, error, contextConfig.logger); } } + + private static buildContext( + step: PendingStepExecution, + cfg: StepContextConfig, + ): ExecutionContext { + return { + ...step, + model: cfg.aiClient.getModel(step.stepDefinition.aiConfigName), + agentPort: cfg.agentPort, + workflowPort: cfg.workflowPort, + runStore: cfg.runStore, + logger: cfg.logger, + }; + } } diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 7dff911c83..becf15631f 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -1,15 +1,15 @@ +import type { StepContextConfig } from './executors/step-executor-factory'; import type { AgentPort } from './ports/agent-port'; import type { Logger } from './ports/logger-port'; import type { RunStore } from './ports/run-store'; import type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; -import type { ExecutionContext, PendingStepExecution } from './types/execution'; -import type { StepOutcome } from './types/step-outcome'; +import type { PendingStepExecution, StepExecutionResult } from './types/execution'; import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; +import { causeMessage } from './errors'; import StepExecutorFactory from './executors/step-executor-factory'; import ExecutorHttpServer from './http/executor-http-server'; -import { StepType } from './types/step-definition'; export interface RunnerConfig { agentPort: AgentPort; @@ -43,21 +43,6 @@ export default class Runner { return `${step.runId}:${step.stepId}`; } - private static stepOutcomeType( - step: PendingStepExecution, - ): 'condition' | 'record-task' | 'mcp-task' { - if (step.stepDefinition.type === StepType.Condition) return 'condition'; - if (step.stepDefinition.type === StepType.McpTask) return 'mcp-task'; - - return 'record-task'; - } - - private static causeMessage(error: unknown): string | undefined { - const { cause } = error as { cause?: unknown }; - - return cause instanceof Error ? cause.message : undefined; - } - constructor(config: RunnerConfig) { this.config = config; this.logger = config.logger ?? new ConsoleLogger(); @@ -152,50 +137,42 @@ export default class Runner { const key = Runner.stepKey(step); this.inFlightSteps.add(key); - let stepOutcome: StepOutcome; + let result: StepExecutionResult; try { - const context = this.buildContext(step); - const executor = await StepExecutorFactory.create(step, context, loadTools); - ({ stepOutcome } = await executor.execute()); + const executor = await StepExecutorFactory.create(step, this.contextConfig, loadTools); + result = await executor.execute(); } catch (error) { - this.logger.error('Step execution failed unexpectedly', { + // This block should never execute: the factory and executor contracts guarantee no rejection. + // It guards against future regressions. + this.logger.error('FATAL: executor contract violated — step outcome not reported', { runId: step.runId, stepId: step.stepId, - stepIndex: step.stepIndex, error: error instanceof Error ? error.message : String(error), - cause: Runner.causeMessage(error), - stack: error instanceof Error ? error.stack : undefined, }); - stepOutcome = { - type: Runner.stepOutcomeType(step), - stepId: step.stepId, - stepIndex: step.stepIndex, - status: 'error', - error: 'An unexpected error occurred.', - } as StepOutcome; + + return; // Cannot report an outcome: the orchestrator will timeout on this step } finally { this.inFlightSteps.delete(key); } try { - await this.config.workflowPort.updateStepExecution(step.runId, stepOutcome); + await this.config.workflowPort.updateStepExecution(step.runId, result.stepOutcome); } catch (error) { this.logger.error('Failed to report step outcome', { runId: step.runId, stepId: step.stepId, stepIndex: step.stepIndex, error: error instanceof Error ? error.message : String(error), - cause: Runner.causeMessage(error), + cause: causeMessage(error), stack: error instanceof Error ? error.stack : undefined, }); } } - private buildContext(step: PendingStepExecution): ExecutionContext { + private get contextConfig(): StepContextConfig { return { - ...step, - model: this.config.aiClient.getModel(step.stepDefinition.aiConfigName), + aiClient: this.config.aiClient, agentPort: this.config.agentPort, workflowPort: this.config.workflowPort, runStore: this.config.runStore, diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 3df0671807..3ec08b3345 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -28,6 +28,10 @@ export interface StepExecutionResult { stepOutcome: StepOutcome; } +export interface IStepExecutor { + execute(): Promise; +} + export interface ExecutionContext { readonly runId: string; readonly stepId: string; diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index b1fbe2d467..3421b60176 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -1,5 +1,7 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ +import { StepType } from './step-definition'; + export type BaseStepStatus = 'success' | 'error'; /** Condition steps can fall back to human decision when the AI is uncertain. */ @@ -41,3 +43,10 @@ export interface McpTaskStepOutcome extends BaseStepOutcome { } export type StepOutcome = ConditionStepOutcome | RecordTaskStepOutcome | McpTaskStepOutcome; + +export function stepTypeToOutcomeType(type: StepType): 'condition' | 'record-task' | 'mcp-task' { + if (type === StepType.Condition) return 'condition'; + if (type === StepType.McpTask) return 'mcp-task'; + + return 'record-task'; +} diff --git a/packages/workflow-executor/test/executors/error-step-executor.test.ts b/packages/workflow-executor/test/executors/error-step-executor.test.ts new file mode 100644 index 0000000000..a237d3f13d --- /dev/null +++ b/packages/workflow-executor/test/executors/error-step-executor.test.ts @@ -0,0 +1,245 @@ +import type { Logger } from '../../src/ports/logger-port'; +import type { PendingStepExecution } from '../../src/types/execution'; + +import ErrorStepExecutor from '../../src/executors/error-step-executor'; +import { StepType } from '../../src/types/step-definition'; +import { stepTypeToOutcomeType } from '../../src/types/step-outcome'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeStep( + overrides: Partial & { stepType?: StepType } = {}, +): PendingStepExecution { + const { stepType = StepType.ReadRecord, ...rest } = overrides; + + let stepDefinition: PendingStepExecution['stepDefinition']; + + if (stepType === StepType.Condition) { + stepDefinition = { type: StepType.Condition, options: ['opt1', 'opt2'] }; + } else if (stepType === StepType.McpTask) { + stepDefinition = { type: StepType.McpTask }; + } else { + stepDefinition = { type: stepType as Exclude }; + } + + return { + runId: 'run-1', + stepId: 'step-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, + stepDefinition, + previousSteps: [], + ...rest, + }; +} + +function makeLogger(): jest.Mocked { + return { error: jest.fn() }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('stepTypeToOutcomeType', () => { + it('maps Condition to condition', () => { + expect(stepTypeToOutcomeType(StepType.Condition)).toBe('condition'); + }); + + it('maps McpTask to mcp-task', () => { + expect(stepTypeToOutcomeType(StepType.McpTask)).toBe('mcp-task'); + }); + + it('maps ReadRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.ReadRecord)).toBe('record-task'); + }); + + it('maps UpdateRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.UpdateRecord)).toBe('record-task'); + }); + + it('maps TriggerAction to record-task', () => { + expect(stepTypeToOutcomeType(StepType.TriggerAction)).toBe('record-task'); + }); + + it('maps LoadRelatedRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.LoadRelatedRecord)).toBe('record-task'); + }); + + it('falls through to record-task for an unknown future step type', () => { + expect(stepTypeToOutcomeType('future-step-type' as StepType)).toBe('record-task'); + }); +}); + +describe('ErrorStepExecutor', () => { + describe('contract', () => { + it('execute() always resolves and never rejects', async () => { + const executor = new ErrorStepExecutor(makeStep(), new Error('boom'), makeLogger()); + + await expect(executor.execute()).resolves.toBeDefined(); + }); + }); + + describe('logging', () => { + it('logs with runId, stepId, stepIndex, error message and stack', async () => { + const error = new Error('something went wrong'); + const step = makeStep({ runId: 'run-42', stepId: 'step-99', stepIndex: 3 }); + const logger = makeLogger(); + const executor = new ErrorStepExecutor(step, error, logger); + + await executor.execute(); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + runId: 'run-42', + stepId: 'step-99', + stepIndex: 3, + error: 'something went wrong', + stack: expect.any(String), + }), + ); + }); + + it('handles a non-Error throwable (string)', async () => { + const step = makeStep(); + const logger = makeLogger(); + const executor = new ErrorStepExecutor(step, 'string error', logger); + + await executor.execute(); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + error: 'string error', + stack: undefined, + }), + ); + }); + + it('logs cause message when error has an Error cause', async () => { + const rootCause = new Error('root cause message'); + const error = new Error('wrapper'); + (error as Error & { cause: Error }).cause = rootCause; + const logger = makeLogger(); + const executor = new ErrorStepExecutor(makeStep(), error, logger); + + await executor.execute(); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: 'root cause message' }), + ); + }); + + it('logs cause as undefined when error cause is not an Error instance', async () => { + const error = new Error('wrapper'); + (error as Error & { cause: string }).cause = 'plain string cause'; + const logger = makeLogger(); + const executor = new ErrorStepExecutor(makeStep(), error, logger); + + await executor.execute(); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: undefined }), + ); + }); + }); + + describe('outcome type mapping', () => { + it('returns type condition for Condition steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.Condition }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('condition'); + }); + + it('returns type mcp-task for McpTask steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.McpTask }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('mcp-task'); + }); + + it('returns type record-task for ReadRecord steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.ReadRecord }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('record-task'); + }); + + it('returns type record-task for UpdateRecord steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.UpdateRecord }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('record-task'); + }); + + it('returns type record-task for TriggerAction steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.TriggerAction }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('record-task'); + }); + + it('returns type record-task for LoadRelatedRecord steps', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepType: StepType.LoadRelatedRecord }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.type).toBe('record-task'); + }); + }); + + describe('outcome content', () => { + it('returns status error with generic user-facing message', async () => { + const executor = new ErrorStepExecutor( + makeStep(), + new Error('internal detail'), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.error).toBe('An unexpected error occurred.'); + }); + + it('returns correct stepId and stepIndex', async () => { + const executor = new ErrorStepExecutor( + makeStep({ stepId: 'my-step', stepIndex: 5 }), + new Error(), + makeLogger(), + ); + const { stepOutcome } = await executor.execute(); + + expect(stepOutcome.stepId).toBe('my-step'); + expect(stepOutcome.stepIndex).toBe(5); + }); + }); +}); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index ec6e6ebf7e..2b1639ded0 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -1,3 +1,4 @@ +import type { StepContextConfig } from '../src/executors/step-executor-factory'; import type { AgentPort } from '../src/ports/agent-port'; import type { Logger } from '../src/ports/logger-port'; import type { RunStore } from '../src/ports/run-store'; @@ -8,6 +9,7 @@ import type { AiClient, BaseChatModel } from '@forestadmin/ai-proxy'; import BaseStepExecutor from '../src/executors/base-step-executor'; import ConditionStepExecutor from '../src/executors/condition-step-executor'; +import ErrorStepExecutor from '../src/executors/error-step-executor'; import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; import McpTaskStepExecutor from '../src/executors/mcp-task-step-executor'; import ReadRecordStepExecutor from '../src/executors/read-record-step-executor'; @@ -332,18 +334,30 @@ describe('deduplication', () => { expect(executeSpy).toHaveBeenCalledTimes(2); }); - it('removes the step key even if execute throws', async () => { + it('removes the step key even when executor construction fails', async () => { const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-throws' }); workflowPort.getPendingStepExecutions.mockResolvedValue([step]); - executeSpy.mockRejectedValueOnce(new Error('execution error')); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('construction error'); + }); - runner = new Runner(createRunnerConfig({ workflowPort })); + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); await runner.triggerPoll('run-1'); await runner.triggerPoll('run-1'); - expect(executeSpy).toHaveBeenCalledTimes(2); + // Both polls completed: the step key was cleared after the first (failed) execution + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(2); + // First poll produced an error outcome from the construction failure + expect(workflowPort.updateStepExecution).toHaveBeenNthCalledWith( + 1, + 'run-1', + expect.objectContaining({ status: 'error', error: 'An unexpected error occurred.' }), + ); }); }); @@ -460,86 +474,76 @@ describe('MCP lazy loading (via once thunk)', () => { // getExecutor — factory // --------------------------------------------------------------------------- -function makeCtxStepDef(type: StepType) { - if (type === StepType.Condition) { - return { type: StepType.Condition as const, options: ['opt1'] as [string, ...string[]] }; - } - - if (type === StepType.McpTask) { - return { type: StepType.McpTask as const }; - } - - return { type: type as Exclude }; -} - describe('StepExecutorFactory.create — factory', () => { - const makeCtx = (type: StepType) => ({ - runId: 'run-1', - stepId: 'step-1', - stepIndex: 0, - baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, - stepDefinition: makeCtxStepDef(type), - model: {} as BaseChatModel, + const makeContextConfig = (): StepContextConfig => ({ + aiClient: { + getModel: jest.fn().mockReturnValue({} as BaseChatModel), + } as unknown as AiClient, agentPort: {} as AgentPort, workflowPort: {} as WorkflowPort, runStore: {} as RunStore, - previousSteps: [] as [], logger: { error: jest.fn() }, }); it('dispatches Condition steps to ConditionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.Condition }); - const ctx = makeCtx(StepType.Condition); - const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); expect(executor).toBeInstanceOf(ConditionStepExecutor); }); it('dispatches ReadRecord steps to ReadRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.ReadRecord }); - const ctx = makeCtx(StepType.ReadRecord); - const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); expect(executor).toBeInstanceOf(ReadRecordStepExecutor); }); it('dispatches UpdateRecord steps to UpdateRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.UpdateRecord }); - const ctx = makeCtx(StepType.UpdateRecord); - const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); expect(executor).toBeInstanceOf(UpdateRecordStepExecutor); }); it('dispatches TriggerAction steps to TriggerRecordActionStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.TriggerAction }); - const ctx = makeCtx(StepType.TriggerAction); - const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); expect(executor).toBeInstanceOf(TriggerRecordActionStepExecutor); }); it('dispatches LoadRelatedRecord steps to LoadRelatedRecordStepExecutor', async () => { const step = makePendingStep({ stepType: StepType.LoadRelatedRecord }); - const ctx = makeCtx(StepType.LoadRelatedRecord); - const executor = await StepExecutorFactory.create(step, ctx, jest.fn()); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); }); it('dispatches McpTask steps to McpTaskStepExecutor and calls loadTools', async () => { const step = makePendingStep({ stepType: StepType.McpTask }); - const ctx = makeCtx(StepType.McpTask); const loadTools = jest.fn().mockResolvedValue([]); - const executor = await StepExecutorFactory.create(step, ctx, loadTools); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); expect(executor).toBeInstanceOf(McpTaskStepExecutor); expect(loadTools).toHaveBeenCalledTimes(1); }); - it('throws for an unknown step type', async () => { + it('returns an ErrorStepExecutor for an unknown step type', async () => { const step = { ...makePendingStep(), stepDefinition: { type: 'unknown-type' as StepType }, } as unknown as PendingStepExecution; - const ctx = makeCtx(StepType.ReadRecord); - await expect(StepExecutorFactory.create(step, ctx, jest.fn())).rejects.toThrow( - 'Unknown step type: unknown-type', - ); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(ErrorStepExecutor); + const { stepOutcome } = await executor.execute(); + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.error).toBe('An unexpected error occurred.'); + }); + + it('returns an ErrorStepExecutor when loadTools rejects for a McpTask step', async () => { + const step = makePendingStep({ stepType: StepType.McpTask }); + const loadTools = jest.fn().mockRejectedValueOnce(new Error('MCP server down')); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); + expect(executor).toBeInstanceOf(ErrorStepExecutor); + const { stepOutcome } = await executor.execute(); + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.type).toBe('mcp-task'); + expect(stepOutcome.error).toBe('An unexpected error occurred.'); }); }); @@ -609,15 +613,24 @@ describe('error handling', () => { ); }); - it('logs unexpected errors with runId, stepId, and stack', async () => { + it('logs unexpected errors with runId, stepId, and stack when getModel throws', async () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); + const aiClient = createMockAiClient(); const error = new Error('something blew up'); const step = makePendingStep({ runId: 'run-2', stepId: 'step-log' }); workflowPort.getPendingStepExecutions.mockResolvedValue([step]); - executeSpy.mockRejectedValueOnce(error); + aiClient.getModel.mockImplementationOnce(() => { + throw error; + }); - runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiClient: aiClient as unknown as AiClient, + logger: mockLogger, + }), + ); await runner.triggerPoll('run-2'); expect(mockLogger.error).toHaveBeenCalledWith( @@ -632,18 +645,69 @@ describe('error handling', () => { ); }); - it('does not re-throw if updateStepExecution fails in the fallback path', async () => { + it('does not re-throw if updateStepExecution fails after a construction error', async () => { const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-fallback' }); workflowPort.getPendingStepExecutions.mockResolvedValue([step]); - executeSpy.mockRejectedValueOnce(new Error('exec error')); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('construction error'); + }); workflowPort.updateStepExecution.mockRejectedValueOnce(new Error('update failed')); - runner = new Runner(createRunnerConfig({ workflowPort })); + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); }); + it('logs FATAL and does not call updateStepExecution if executor.execute() rejects', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-fatal' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + + // Simulate a broken executor that violates the never-throw contract + jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ + execute: jest.fn().mockRejectedValueOnce(new Error('contract violated')), + }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'FATAL: executor contract violated — step outcome not reported', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-fatal', + error: 'contract violated', + }), + ); + expect(workflowPort.updateStepExecution).not.toHaveBeenCalled(); + }); + + it('reports an outcome when getModel throws a non-Error throwable', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-string-throw' }); + workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + aiClient.getModel.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'plain string error'; + }); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ status: 'error', error: 'An unexpected error occurred.' }), + ); + }); + it('catches getPendingStepExecutions failure, logs it, and reschedules', async () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); From 0870aa452f00995281adf33683212dca43f2ec92 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 17:48:07 +0100 Subject: [PATCH 30/34] refactor(workflow-executor): remove ErrorStepExecutor, inline error handling in factory ErrorStepExecutor had no meaningful state once logging was moved out of it. The factory catch block now logs directly and returns an inline IStepExecutor. stepTypeToOutcomeType unit tests moved to test/types/step-outcome.test.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/error-step-executor.ts | 34 --- .../src/executors/step-executor-factory.ts | 32 ++- .../executors/error-step-executor.test.ts | 245 ------------------ .../workflow-executor/test/runner.test.ts | 52 +++- .../test/types/step-outcome.test.ts | 32 +++ 5 files changed, 107 insertions(+), 288 deletions(-) delete mode 100644 packages/workflow-executor/src/executors/error-step-executor.ts delete mode 100644 packages/workflow-executor/test/executors/error-step-executor.test.ts create mode 100644 packages/workflow-executor/test/types/step-outcome.test.ts diff --git a/packages/workflow-executor/src/executors/error-step-executor.ts b/packages/workflow-executor/src/executors/error-step-executor.ts deleted file mode 100644 index a28251b6d5..0000000000 --- a/packages/workflow-executor/src/executors/error-step-executor.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Logger } from '../ports/logger-port'; -import type { IStepExecutor, PendingStepExecution, StepExecutionResult } from '../types/execution'; - -import { causeMessage } from '../errors'; -import { stepTypeToOutcomeType } from '../types/step-outcome'; - -export default class ErrorStepExecutor implements IStepExecutor { - constructor( - private readonly step: PendingStepExecution, - private readonly error: unknown, - private readonly logger: Logger, - ) {} - - async execute(): Promise { - this.logger.error('Step execution failed unexpectedly', { - runId: this.step.runId, - stepId: this.step.stepId, - stepIndex: this.step.stepIndex, - error: this.error instanceof Error ? this.error.message : String(this.error), - cause: causeMessage(this.error), - stack: this.error instanceof Error ? this.error.stack : undefined, - }); - - return { - stepOutcome: { - type: stepTypeToOutcomeType(this.step.stepDefinition.type), - stepId: this.step.stepId, - stepIndex: this.step.stepIndex, - status: 'error', - error: 'An unexpected error occurred.', - }, - }; - } -} diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 518555db1f..2f321dd638 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -2,7 +2,12 @@ import type { AgentPort } from '../ports/agent-port'; import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; -import type { ExecutionContext, IStepExecutor, PendingStepExecution } from '../types/execution'; +import type { + ExecutionContext, + IStepExecutor, + PendingStepExecution, + StepExecutionResult, +} from '../types/execution'; import type { ConditionStepDefinition, McpTaskStepDefinition, @@ -10,15 +15,15 @@ import type { } from '../types/step-definition'; import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; -import { StepStateError } from '../errors'; +import { StepStateError, causeMessage } from '../errors'; import ConditionStepExecutor from './condition-step-executor'; -import ErrorStepExecutor from './error-step-executor'; import LoadRelatedRecordStepExecutor from './load-related-record-step-executor'; import McpTaskStepExecutor from './mcp-task-step-executor'; import ReadRecordStepExecutor from './read-record-step-executor'; import TriggerRecordActionStepExecutor from './trigger-record-action-step-executor'; import UpdateRecordStepExecutor from './update-record-step-executor'; import { StepType } from '../types/step-definition'; +import { stepTypeToOutcomeType } from '../types/step-outcome'; export interface StepContextConfig { aiClient: AiClient; @@ -65,7 +70,26 @@ export default class StepExecutorFactory { ); } } catch (error) { - return new ErrorStepExecutor(step, error, contextConfig.logger); + contextConfig.logger.error('Step execution failed unexpectedly', { + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + return { + execute: async (): Promise => ({ + stepOutcome: { + type: stepTypeToOutcomeType(step.stepDefinition.type), + stepId: step.stepId, + stepIndex: step.stepIndex, + status: 'error', + error: 'An unexpected error occurred.', + }, + }), + }; } } diff --git a/packages/workflow-executor/test/executors/error-step-executor.test.ts b/packages/workflow-executor/test/executors/error-step-executor.test.ts deleted file mode 100644 index a237d3f13d..0000000000 --- a/packages/workflow-executor/test/executors/error-step-executor.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { Logger } from '../../src/ports/logger-port'; -import type { PendingStepExecution } from '../../src/types/execution'; - -import ErrorStepExecutor from '../../src/executors/error-step-executor'; -import { StepType } from '../../src/types/step-definition'; -import { stepTypeToOutcomeType } from '../../src/types/step-outcome'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeStep( - overrides: Partial & { stepType?: StepType } = {}, -): PendingStepExecution { - const { stepType = StepType.ReadRecord, ...rest } = overrides; - - let stepDefinition: PendingStepExecution['stepDefinition']; - - if (stepType === StepType.Condition) { - stepDefinition = { type: StepType.Condition, options: ['opt1', 'opt2'] }; - } else if (stepType === StepType.McpTask) { - stepDefinition = { type: StepType.McpTask }; - } else { - stepDefinition = { type: stepType as Exclude }; - } - - return { - runId: 'run-1', - stepId: 'step-1', - stepIndex: 0, - baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, - stepDefinition, - previousSteps: [], - ...rest, - }; -} - -function makeLogger(): jest.Mocked { - return { error: jest.fn() }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('stepTypeToOutcomeType', () => { - it('maps Condition to condition', () => { - expect(stepTypeToOutcomeType(StepType.Condition)).toBe('condition'); - }); - - it('maps McpTask to mcp-task', () => { - expect(stepTypeToOutcomeType(StepType.McpTask)).toBe('mcp-task'); - }); - - it('maps ReadRecord to record-task', () => { - expect(stepTypeToOutcomeType(StepType.ReadRecord)).toBe('record-task'); - }); - - it('maps UpdateRecord to record-task', () => { - expect(stepTypeToOutcomeType(StepType.UpdateRecord)).toBe('record-task'); - }); - - it('maps TriggerAction to record-task', () => { - expect(stepTypeToOutcomeType(StepType.TriggerAction)).toBe('record-task'); - }); - - it('maps LoadRelatedRecord to record-task', () => { - expect(stepTypeToOutcomeType(StepType.LoadRelatedRecord)).toBe('record-task'); - }); - - it('falls through to record-task for an unknown future step type', () => { - expect(stepTypeToOutcomeType('future-step-type' as StepType)).toBe('record-task'); - }); -}); - -describe('ErrorStepExecutor', () => { - describe('contract', () => { - it('execute() always resolves and never rejects', async () => { - const executor = new ErrorStepExecutor(makeStep(), new Error('boom'), makeLogger()); - - await expect(executor.execute()).resolves.toBeDefined(); - }); - }); - - describe('logging', () => { - it('logs with runId, stepId, stepIndex, error message and stack', async () => { - const error = new Error('something went wrong'); - const step = makeStep({ runId: 'run-42', stepId: 'step-99', stepIndex: 3 }); - const logger = makeLogger(); - const executor = new ErrorStepExecutor(step, error, logger); - - await executor.execute(); - - expect(logger.error).toHaveBeenCalledWith( - 'Step execution failed unexpectedly', - expect.objectContaining({ - runId: 'run-42', - stepId: 'step-99', - stepIndex: 3, - error: 'something went wrong', - stack: expect.any(String), - }), - ); - }); - - it('handles a non-Error throwable (string)', async () => { - const step = makeStep(); - const logger = makeLogger(); - const executor = new ErrorStepExecutor(step, 'string error', logger); - - await executor.execute(); - - expect(logger.error).toHaveBeenCalledWith( - 'Step execution failed unexpectedly', - expect.objectContaining({ - error: 'string error', - stack: undefined, - }), - ); - }); - - it('logs cause message when error has an Error cause', async () => { - const rootCause = new Error('root cause message'); - const error = new Error('wrapper'); - (error as Error & { cause: Error }).cause = rootCause; - const logger = makeLogger(); - const executor = new ErrorStepExecutor(makeStep(), error, logger); - - await executor.execute(); - - expect(logger.error).toHaveBeenCalledWith( - 'Step execution failed unexpectedly', - expect.objectContaining({ cause: 'root cause message' }), - ); - }); - - it('logs cause as undefined when error cause is not an Error instance', async () => { - const error = new Error('wrapper'); - (error as Error & { cause: string }).cause = 'plain string cause'; - const logger = makeLogger(); - const executor = new ErrorStepExecutor(makeStep(), error, logger); - - await executor.execute(); - - expect(logger.error).toHaveBeenCalledWith( - 'Step execution failed unexpectedly', - expect.objectContaining({ cause: undefined }), - ); - }); - }); - - describe('outcome type mapping', () => { - it('returns type condition for Condition steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.Condition }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('condition'); - }); - - it('returns type mcp-task for McpTask steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.McpTask }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('mcp-task'); - }); - - it('returns type record-task for ReadRecord steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.ReadRecord }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('record-task'); - }); - - it('returns type record-task for UpdateRecord steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.UpdateRecord }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('record-task'); - }); - - it('returns type record-task for TriggerAction steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.TriggerAction }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('record-task'); - }); - - it('returns type record-task for LoadRelatedRecord steps', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepType: StepType.LoadRelatedRecord }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.type).toBe('record-task'); - }); - }); - - describe('outcome content', () => { - it('returns status error with generic user-facing message', async () => { - const executor = new ErrorStepExecutor( - makeStep(), - new Error('internal detail'), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.status).toBe('error'); - expect(stepOutcome.error).toBe('An unexpected error occurred.'); - }); - - it('returns correct stepId and stepIndex', async () => { - const executor = new ErrorStepExecutor( - makeStep({ stepId: 'my-step', stepIndex: 5 }), - new Error(), - makeLogger(), - ); - const { stepOutcome } = await executor.execute(); - - expect(stepOutcome.stepId).toBe('my-step'); - expect(stepOutcome.stepIndex).toBe(5); - }); - }); -}); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 2b1639ded0..30384cd4f1 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -9,7 +9,6 @@ import type { AiClient, BaseChatModel } from '@forestadmin/ai-proxy'; import BaseStepExecutor from '../src/executors/base-step-executor'; import ConditionStepExecutor from '../src/executors/condition-step-executor'; -import ErrorStepExecutor from '../src/executors/error-step-executor'; import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; import McpTaskStepExecutor from '../src/executors/mcp-task-step-executor'; import ReadRecordStepExecutor from '../src/executors/read-record-step-executor'; @@ -523,28 +522,71 @@ describe('StepExecutorFactory.create — factory', () => { expect(loadTools).toHaveBeenCalledTimes(1); }); - it('returns an ErrorStepExecutor for an unknown step type', async () => { + it('returns an executor with an error outcome for an unknown step type', async () => { const step = { ...makePendingStep(), stepDefinition: { type: 'unknown-type' as StepType }, } as unknown as PendingStepExecution; const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); - expect(executor).toBeInstanceOf(ErrorStepExecutor); const { stepOutcome } = await executor.execute(); expect(stepOutcome.status).toBe('error'); expect(stepOutcome.error).toBe('An unexpected error occurred.'); }); - it('returns an ErrorStepExecutor when loadTools rejects for a McpTask step', async () => { + it('returns an executor with an error outcome when loadTools rejects for a McpTask step', async () => { const step = makePendingStep({ stepType: StepType.McpTask }); const loadTools = jest.fn().mockRejectedValueOnce(new Error('MCP server down')); const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); - expect(executor).toBeInstanceOf(ErrorStepExecutor); const { stepOutcome } = await executor.execute(); expect(stepOutcome.status).toBe('error'); expect(stepOutcome.type).toBe('mcp-task'); expect(stepOutcome.error).toBe('An unexpected error occurred.'); }); + + it('logs cause message when construction error has an Error cause', async () => { + const rootCause = new Error('root cause'); + const error = new Error('wrapper'); + (error as Error & { cause: Error }).cause = rootCause; + const logger = { error: jest.fn() }; + const contextConfig: StepContextConfig = { + ...makeContextConfig(), + aiClient: { + getModel: jest.fn().mockImplementationOnce(() => { + throw error; + }), + } as unknown as AiClient, + logger, + }; + + await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: 'root cause' }), + ); + }); + + 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 = { error: jest.fn() }; + const contextConfig: StepContextConfig = { + ...makeContextConfig(), + aiClient: { + getModel: jest.fn().mockImplementationOnce(() => { + throw error; + }), + } as unknown as AiClient, + logger, + }; + + await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: undefined }), + ); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/workflow-executor/test/types/step-outcome.test.ts b/packages/workflow-executor/test/types/step-outcome.test.ts new file mode 100644 index 0000000000..e044e79f84 --- /dev/null +++ b/packages/workflow-executor/test/types/step-outcome.test.ts @@ -0,0 +1,32 @@ +import { StepType } from '../../src/types/step-definition'; +import { stepTypeToOutcomeType } from '../../src/types/step-outcome'; + +describe('stepTypeToOutcomeType', () => { + it('maps Condition to condition', () => { + expect(stepTypeToOutcomeType(StepType.Condition)).toBe('condition'); + }); + + it('maps McpTask to mcp-task', () => { + expect(stepTypeToOutcomeType(StepType.McpTask)).toBe('mcp-task'); + }); + + it('maps ReadRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.ReadRecord)).toBe('record-task'); + }); + + it('maps UpdateRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.UpdateRecord)).toBe('record-task'); + }); + + it('maps TriggerAction to record-task', () => { + expect(stepTypeToOutcomeType(StepType.TriggerAction)).toBe('record-task'); + }); + + it('maps LoadRelatedRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.LoadRelatedRecord)).toBe('record-task'); + }); + + it('falls through to record-task for an unknown future step type', () => { + expect(stepTypeToOutcomeType('future-step-type' as StepType)).toBe('record-task'); + }); +}); From a55c23e9888739589f44b7aec08e527d93767631 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Mon, 23 Mar 2026 18:34:35 +0100 Subject: [PATCH 31/34] feat(workflow-executor): add getPendingStepExecutionsForRun to fix triggerPoll - Add RunNotFoundError (extends Error, not WorkflowExecutorError) - Add getPendingStepExecutionsForRun(runId) to WorkflowPort (returns PendingStepExecution | null) - Implement in ForestServerWorkflowPort with runId query param + URL encoding - triggerPoll uses the new method, throws RunNotFoundError if step is null - handleTrigger returns 404 on RunNotFoundError, rethrows otherwise Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/forest-server-workflow-port.ts | 10 +++ packages/workflow-executor/src/errors.ts | 10 +++ .../src/http/executor-http-server.ts | 15 +++- .../src/ports/workflow-port.ts | 1 + packages/workflow-executor/src/runner.ts | 14 ++-- .../forest-server-workflow-port.test.ts | 34 ++++++++ .../load-related-record-step-executor.test.ts | 1 + .../executors/mcp-task-step-executor.test.ts | 1 + .../read-record-step-executor.test.ts | 1 + ...rigger-record-action-step-executor.test.ts | 1 + .../update-record-step-executor.test.ts | 1 + .../test/http/executor-http-server.test.ts | 22 +++++- .../workflow-executor/test/runner.test.ts | 79 +++++++++++-------- 13 files changed, 150 insertions(+), 40 deletions(-) diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 16037570bd..47f45a6c1f 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -9,6 +9,8 @@ import { ServerUtils } from '@forestadmin/forestadmin-client'; // TODO: finalize route paths with the team — these are placeholders const ROUTES = { pendingStepExecutions: '/liana/v1/workflow-step-executions/pending', + pendingStepExecutionForRun: (runId: string) => + `/liana/v1/workflow-step-executions/pending?runId=${encodeURIComponent(runId)}`, updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`, collectionSchema: (collectionName: string) => `/liana/v1/collections/${collectionName}`, mcpServerConfigs: '/liana/mcp-server-configs-with-details', @@ -29,6 +31,14 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); } + async getPendingStepExecutionsForRun(runId: string): Promise { + return ServerUtils.query( + this.options, + 'get', + ROUTES.pendingStepExecutionForRun(runId), + ); + } + async updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise { await ServerUtils.query( this.options, diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index bd2548b695..4560bafdeb 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -198,3 +198,13 @@ export class McpToolInvocationError extends WorkflowExecutorError { this.cause = cause; } } + +export class RunNotFoundError extends Error { + cause?: unknown; + + constructor(runId: string, cause?: unknown) { + super(`Run "${runId}" not found or unavailable`); + this.name = 'RunNotFoundError'; + if (cause !== undefined) this.cause = cause; + } +} diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index cc96d395bf..10062deddd 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -7,6 +7,8 @@ import Router from '@koa/router'; import http from 'http'; import Koa from 'koa'; +import { RunNotFoundError } from '../errors'; + export interface ExecutorHttpServerOptions { port: number; runStore: RunStore; @@ -88,7 +90,18 @@ export default class ExecutorHttpServer { private async handleTrigger(ctx: Koa.Context): Promise { const { runId } = ctx.params; - await this.options.runner.triggerPoll(runId); + try { + await this.options.runner.triggerPoll(runId); + } catch (err) { + if (err instanceof RunNotFoundError) { + ctx.status = 404; + ctx.body = { error: 'Run not found or unavailable' }; + + return; + } + + throw err; + } ctx.status = 200; ctx.body = { triggered: true }; diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index 110ac3fb42..223123b756 100644 --- a/packages/workflow-executor/src/ports/workflow-port.ts +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -9,6 +9,7 @@ export type { McpConfiguration }; export interface WorkflowPort { getPendingStepExecutions(): Promise; + getPendingStepExecutionsForRun(runId: string): Promise; updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise; getCollectionSchema(collectionName: string): Promise; getMcpServerConfigs(): Promise; diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index becf15631f..b97a8260aa 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -7,7 +7,7 @@ import type { PendingStepExecution, StepExecutionResult } from './types/executio import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; -import { causeMessage } from './errors'; +import { RunNotFoundError, causeMessage } from './errors'; import StepExecutorFactory from './executors/step-executor-factory'; import ExecutorHttpServer from './http/executor-http-server'; @@ -89,12 +89,14 @@ export default class Runner { } async triggerPoll(runId: string): Promise { - const steps = await this.config.workflowPort.getPendingStepExecutions(); - const pending = steps.filter( - s => s.runId === runId && !this.inFlightSteps.has(Runner.stepKey(s)), - ); + const step = await this.config.workflowPort.getPendingStepExecutionsForRun(runId); + + if (!step) throw new RunNotFoundError(runId); + + if (this.inFlightSteps.has(Runner.stepKey(step))) return; + const loadTools = Runner.once(() => this.fetchRemoteTools()); - await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); + await this.executeStep(step, loadTools); } private schedulePoll(): void { diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 9e69a04eaf..8b38812dff 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 @@ -38,6 +38,34 @@ describe('ForestServerWorkflowPort', () => { }); }); + describe('getPendingStepExecutionsForRun', () => { + it('calls the pending step execution route with the runId query param', async () => { + const step = { runId: 'run-42' } as PendingStepExecution; + mockQuery.mockResolvedValue(step); + + const result = await port.getPendingStepExecutionsForRun('run-42'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/v1/workflow-step-executions/pending?runId=run-42', + ); + expect(result).toBe(step); + }); + + it('encodes special characters in the runId', async () => { + mockQuery.mockResolvedValue({} as PendingStepExecution); + + await port.getPendingStepExecutionsForRun('run/42 special'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/v1/workflow-step-executions/pending?runId=run%2F42%20special', + ); + }); + }); + describe('updateStepExecution', () => { it('should post step outcome to the complete route', async () => { mockQuery.mockResolvedValue(undefined); @@ -101,5 +129,11 @@ describe('ForestServerWorkflowPort', () => { await expect(port.getPendingStepExecutions()).rejects.toThrow('Network error'); }); + + it('should propagate errors from getPendingStepExecutionsForRun', async () => { + mockQuery.mockRejectedValue(new Error('Network error')); + + await expect(port.getPendingStepExecutionsForRun('run-1')).rejects.toThrow('Network error'); + }); }); }); 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 bcd5f1dc0c..573cd00ecd 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 @@ -80,6 +80,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts index e487906aab..5525014ddf 100644 --- a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -49,6 +49,7 @@ function makeMockRunStore(overrides: Partial = {}): RunStore { function makeMockWorkflowPort(): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest.fn().mockResolvedValue({ collectionName: 'customers', 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 bc4c0696ab..cee40bc8c3 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 @@ -73,6 +73,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() 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 f67fd3af2a..7bb0a77df3 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 @@ -68,6 +68,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() 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 dc8fffd563..00ed053d06 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 @@ -73,6 +73,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() 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 4e57d24e4f..e660b926a6 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -3,6 +3,7 @@ import type Runner from '../../src/runner'; import request from 'supertest'; +import { RunNotFoundError } from '../../src/errors'; import ExecutorHttpServer from '../../src/http/executor-http-server'; function createMockRunStore(overrides: Partial = {}): RunStore { @@ -79,9 +80,26 @@ describe('ExecutorHttpServer', () => { expect(runner.triggerPoll).toHaveBeenCalledWith('run-1'); }); - it('should propagate errors from runner', async () => { + it('returns 404 when triggerPoll rejects with RunNotFoundError', async () => { const runner = createMockRunner({ - triggerPoll: jest.fn().mockRejectedValue(new Error('poll failed')), + triggerPoll: jest.fn().mockRejectedValue(new RunNotFoundError('run-1')), + }); + + const server = new ExecutorHttpServer({ + port: 0, + runStore: createMockRunStore(), + runner, + }); + + const response = await request(server.callback).post('/runs/run-1/trigger'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Run not found or unavailable' }); + }); + + it('returns 500 when triggerPoll rejects with an unexpected error', async () => { + const runner = createMockRunner({ + triggerPoll: jest.fn().mockRejectedValue(new Error('unexpected')), }); const server = new ExecutorHttpServer({ diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 30384cd4f1..52ba1c0c3b 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -7,6 +7,7 @@ import type { PendingStepExecution } from '../src/types/execution'; import type { StepDefinition } from '../src/types/step-definition'; import type { AiClient, BaseChatModel } from '@forestadmin/ai-proxy'; +import { RunNotFoundError } from '../src/errors'; import BaseStepExecutor from '../src/executors/base-step-executor'; import ConditionStepExecutor from '../src/executors/condition-step-executor'; import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; @@ -38,6 +39,7 @@ const flushPromises = async () => { function createMockWorkflowPort(): jest.Mocked { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn(), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest.fn(), getMcpServerConfigs: jest.fn().mockResolvedValue([]), @@ -288,7 +290,7 @@ describe('deduplication', () => { it('skips a step whose key is already in inFlightSteps', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'inflight-step' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); // Block the first execution so the key stays in-flight const unblockRef = { fn: (): void => {} }; @@ -309,7 +311,7 @@ describe('deduplication', () => { runner = new Runner(createRunnerConfig({ workflowPort })); const poll1 = runner.triggerPoll('run-1'); - await Promise.resolve(); // let getPendingStepExecutions resolve and step key get added + await Promise.resolve(); // let getPendingStepExecutionsForRun resolve and step key get added // Second poll: step is in-flight → should be skipped await runner.triggerPoll('run-1'); @@ -323,7 +325,7 @@ describe('deduplication', () => { it('removes the step key after successful execution', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-dedup' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); runner = new Runner(createRunnerConfig({ workflowPort })); @@ -337,7 +339,7 @@ describe('deduplication', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-throws' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { throw new Error('construction error'); }); @@ -365,25 +367,24 @@ describe('deduplication', () => { // --------------------------------------------------------------------------- describe('triggerPoll', () => { - it('executes only steps for the given runId', async () => { + it('calls getPendingStepExecutionsForRun with the given runId and executes the step', async () => { const workflowPort = createMockWorkflowPort(); - const stepA = makePendingStep({ runId: 'run-A', stepId: 'step-a' }); - const stepB = makePendingStep({ runId: 'run-B', stepId: 'step-b' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([stepA, stepB]); + const step = makePendingStep({ runId: 'run-A', stepId: 'step-a' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); runner = new Runner(createRunnerConfig({ workflowPort })); await runner.triggerPoll('run-A'); + expect(workflowPort.getPendingStepExecutionsForRun).toHaveBeenCalledWith('run-A'); + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); expect(executeSpy).toHaveBeenCalledTimes(1); - // The one execution was for run-A expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-A', expect.anything()); - expect(workflowPort.updateStepExecution).not.toHaveBeenCalledWith('run-B', expect.anything()); }); it('skips in-flight steps', async () => { const workflowPort = createMockWorkflowPort(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-inflight' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); const unblockRef = { fn: (): void => {} }; executeSpy.mockReturnValueOnce( @@ -413,18 +414,33 @@ describe('triggerPoll', () => { await poll1; }); - it('resolves after all matching steps have settled', async () => { + it('resolves after the step has settled', async () => { const workflowPort = createMockWorkflowPort(); - const steps = [ - makePendingStep({ runId: 'run-1', stepId: 'step-a' }), - makePendingStep({ runId: 'run-1', stepId: 'step-b' }), - ]; - workflowPort.getPendingStepExecutions.mockResolvedValue(steps); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-a' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); runner = new Runner(createRunnerConfig({ workflowPort })); await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); - expect(executeSpy).toHaveBeenCalledTimes(2); + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it('rejects with RunNotFoundError when getPendingStepExecutionsForRun returns null', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(null); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).rejects.toThrow(RunNotFoundError); + }); + + it('propagates errors from getPendingStepExecutionsForRun as-is', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getPendingStepExecutionsForRun.mockRejectedValue(new Error('Network error')); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).rejects.toThrow('Network error'); }); }); @@ -437,7 +453,7 @@ describe('MCP lazy loading (via once thunk)', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepType: StepType.ReadRecord }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); runner = new Runner( createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), @@ -448,14 +464,15 @@ describe('MCP lazy loading (via once thunk)', () => { expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); }); - it('calls fetchRemoteTools at most once even with multiple McpTask steps', async () => { + it('calls fetchRemoteTools once for an McpTask step', async () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); - const steps = [ - makePendingStep({ runId: 'run-1', stepId: 'step-mcp-1', stepType: StepType.McpTask }), - makePendingStep({ runId: 'run-1', stepId: 'step-mcp-2', stepType: StepType.McpTask }), - ]; - workflowPort.getPendingStepExecutions.mockResolvedValue(steps); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.McpTask, + }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); // Provide a non-empty config so fetchRemoteTools actually calls loadRemoteTools workflowPort.getMcpServerConfigs.mockResolvedValue([{ configs: {} }] as never); @@ -599,7 +616,7 @@ describe('error handling', () => { const mockLogger = createMockLogger(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-err' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { throw new Error('AI not configured'); }); @@ -639,7 +656,7 @@ describe('error handling', () => { stepId: 'step-mcp-err', stepType: StepType.McpTask, }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { throw new Error('AI not configured'); }); @@ -661,7 +678,7 @@ describe('error handling', () => { const aiClient = createMockAiClient(); const error = new Error('something blew up'); const step = makePendingStep({ runId: 'run-2', stepId: 'step-log' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { throw error; }); @@ -691,7 +708,7 @@ describe('error handling', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-fallback' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { throw new Error('construction error'); }); @@ -708,7 +725,7 @@ describe('error handling', () => { const workflowPort = createMockWorkflowPort(); const mockLogger = createMockLogger(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-fatal' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); // Simulate a broken executor that violates the never-throw contract jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ @@ -733,7 +750,7 @@ describe('error handling', () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-string-throw' }); - workflowPort.getPendingStepExecutions.mockResolvedValue([step]); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); aiClient.getModel.mockImplementationOnce(() => { // eslint-disable-next-line @typescript-eslint/no-throw-literal throw 'plain string error'; From d381ac3fc7047cd8af977958b787332ea0054c8f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 24 Mar 2026 09:46:27 +0100 Subject: [PATCH 32/34] fix: add claude --- packages/workflow-executor/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index dbea279bd1..a54d599883 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -80,6 +80,7 @@ src/ - **Dual error messages** — `WorkflowExecutorError` carries two messages: `message` (technical, for dev logs) and `userMessage` (human-readable, surfaced to the Forest Admin UI via `stepOutcome.error`). The mapping happens in a single place: `base-step-executor.ts` uses `error.userMessage` when building the error outcome. When adding a new error subclass, always provide a distinct `userMessage` oriented toward end-users (no collection names, field names, or AI internals). If `userMessage` is omitted in the constructor call, it falls back to `message`. - **displayName in AI tools** — All `DynamicStructuredTool` schemas and system message prompts must use `displayName`, never `fieldName`. `displayName` is a Forest Admin frontend feature that replaces the technical field/relation/action name with a product-oriented label configured by the Forest Admin admin. End users write their workflow prompts using these display names, not the underlying technical names. After an AI tool call returns display names, map them back to `fieldName`/`name` before using them in datasource operations (e.g. filtering record values, calling `getRecord`). - **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. +- **Fetched steps must be executed** — Any step retrieved from the orchestrator via `getPendingStepExecutions()` must be executed. Silently discarding a fetched step (e.g. filtering it out by `runId` after fetching) violates the executor contract: the orchestrator assumes execution is guaranteed once the step is dispatched. The only valid filter before executing is deduplication via `inFlightSteps` (to avoid running the same step twice concurrently). ## Commands From 511f3680793b33a764e880ae5d09352242380717 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 24 Mar 2026 10:33:46 +0100 Subject: [PATCH 33/34] =?UTF-8?q?refactor(workflow-executor):=20simplify?= =?UTF-8?q?=20load-related-record=20pending=20data=20=E2=80=94=20replace?= =?UTF-8?q?=20suggestedRecordId=20+=20selectedRecordId=3F=20with=20a=20sin?= =?UTF-8?q?gle=20selectedRecordId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The executor now writes the AI's pick directly into selectedRecordId. The future PATCH endpoint will overwrite it with the user's choice, removing the need to distinguish between "suggested" and "selected". Co-Authored-By: Claude Sonnet 4.6 --- WORKFLOW-EXECUTOR-CONTRACT.md | 230 ++++++++++++++++++ .../load-related-record-step-executor.ts | 11 +- .../src/types/step-execution-data.ts | 8 +- .../load-related-record-step-executor.test.ts | 21 +- .../step-execution-formatters.test.ts | 2 +- .../executors/step-summary-builder.test.ts | 2 +- 6 files changed, 250 insertions(+), 24 deletions(-) create mode 100644 WORKFLOW-EXECUTOR-CONTRACT.md diff --git a/WORKFLOW-EXECUTOR-CONTRACT.md b/WORKFLOW-EXECUTOR-CONTRACT.md new file mode 100644 index 0000000000..5fc24d0da0 --- /dev/null +++ b/WORKFLOW-EXECUTOR-CONTRACT.md @@ -0,0 +1,230 @@ +# Workflow Executor — Contract Types + +> Types exchanged between the **orchestrator (server)**, the **executor (agent-nodejs)**, and the **frontend**. +> Last updated: 2026-03-24 + +--- + +## 1. Polling + +**`GET /liana/v1/workflow-step-executions/pending?runId=`** + +The executor polls for the current pending step of a run. The server must return **one object** (not an array), or `null` if the run is not found. + +```typescript +interface PendingStepExecution { + runId: string; + stepId: string; + stepIndex: number; + baseRecordRef: RecordRef; + stepDefinition: StepDefinition; + previousSteps: Step[]; + userConfirmed?: boolean; // true = user confirmed a pending action on this step +} +``` + +> **`null` response** → executor throws `RunNotFoundError` → HTTP 404 returned to caller. + +### RecordRef + +Lightweight pointer to a specific record. + +```typescript +interface RecordRef { + collectionName: string; + recordId: Array; + stepIndex: number; // index of the workflow step that loaded this record +} +``` + +### Step + +History entry for an already-executed step (used in `previousSteps`). + +```typescript +interface Step { + stepDefinition: StepDefinition; + stepOutcome: StepOutcome; +} +``` + +### StepDefinition + +Discriminated union on `type`. + +```typescript +type StepDefinition = + | ConditionStepDefinition + | RecordTaskStepDefinition + | McpTaskStepDefinition; + +interface ConditionStepDefinition { + type: "condition"; + options: [string, ...string[]]; // at least one option required + prompt?: string; + aiConfigName?: string; +} + +interface RecordTaskStepDefinition { + type: "read-record" + | "update-record" + | "trigger-action" + | "load-related-record"; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; +} + +interface McpTaskStepDefinition { + type: "mcp-task"; + mcpServerId?: string; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; +} +``` + +### StepOutcome + +What the executor previously reported for each past step (used in `previousSteps`). + +```typescript +type StepOutcome = + | ConditionStepOutcome + | RecordTaskStepOutcome + | McpTaskStepOutcome; + +interface ConditionStepOutcome { + type: "condition"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "manual-decision"; + selectedOption?: string; // present when status = "success" + error?: string; // present when status = "error" +} + +interface RecordTaskStepOutcome { + type: "record-task"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "awaiting-input"; + error?: string; // present when status = "error" +} + +interface McpTaskStepOutcome { + type: "mcp-task"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "awaiting-input"; + error?: string; // present when status = "error" +} +``` + +--- + +## 2. Step Result + +**`POST /liana/v1/workflow-step-executions//complete`** + +After executing a step, the executor posts the outcome back to the server. The body is one of the `StepOutcome` shapes above. + +> ⚠️ **NEVER contains client data** (field values, AI reasoning, etc.) — those stay in the `RunStore` on the client side. + +--- + +## 3. Pending Data + +Steps that require user input pause with `status: "awaiting-input"`. The frontend writes `pendingData` to unblock them, via a dedicated endpoint on the executor HTTP server (route TBD — PRD-240). + +Once written, the frontend calls `POST /runs/:runId/trigger` and the executor resumes with `userConfirmed: true`. + +### update-record — user picks a field + value to write + +```typescript +interface UpdateRecordPendingData { + name: string; // technical field name + displayName: string; // label shown in the UI + value: string; // value chosen by the user +} +``` + +### trigger-action — user confirmation only + +No payload required from the frontend. The executor selects the action and writes `pendingData` itself (action name + displayName) to the RunStore. The frontend just confirms: + +``` +POST /runs/:runId/trigger +``` + +### load-related-record — user picks the relation and/or the record + +The frontend can override **both** the relation (field) and the selected record. + +> **Current status** — The frontend cannot yet override the AI selection. The executor HTTP server does not yet expose the PATCH endpoint described below. Until it is implemented, the executor writes the AI's pick directly into `selectedRecordId`. + +```typescript +// Written by the executor and overwritable by the frontend via PATCH (not yet implemented) +interface LoadRelatedRecordPendingData { + name: string; // technical relation name + displayName: string; // label shown in the UI + relatedCollectionName: string; // collection of the related record + suggestedFields?: string[]; // fields suggested for display + selectedRecordId: Array; // AI's pick; overwritten by the frontend via PATCH +} +``` + +The executor initially writes the AI's pick into `selectedRecordId`. The PATCH endpoint overwrites it (and optionally `name`, `displayName`, `relatedCollectionName`) when the user changes the selection. + +#### Future endpoint — PATCH pending data (not yet implemented) + +``` +PATCH /runs/:runId/steps/:stepIndex/pending-data +``` + +Request body: + +```typescript +{ + selectedRecordId?: Array; // record chosen by the user + name?: string; // relation changed + displayName?: string; // relation changed + relatedCollectionName?: string; // required if name is provided +} +``` + +Response: `204 No Content`. + +The frontend calls this endpoint **before** `POST /runs/:runId/trigger`. On the next poll, `userConfirmed: true` and the executor reads `selectedRecordId` from the RunStore. + +### mcp-task — user confirmation only + +No payload required from the frontend. The executor selects the tool and writes `pendingData` itself (tool name + input) to the RunStore. The frontend just confirms: + +``` +POST /runs/:runId/trigger +``` + +The executor resumes with `userConfirmed: true` and executes the pre-selected tool. + +--- + +## Flow Summary + +``` +Orchestrator ──► GET pending?runId=X ──► Executor + │ + executes step + │ + ┌───────────────┴───────────────┐ + needs input done + │ │ + status: awaiting-input POST /complete + │ (StepOutcome) + Frontend writes pendingData + to executor HTTP server + │ + POST /runs/:runId/trigger + (next poll: userConfirmed = true) + │ + Executor resumes +``` 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 904037723d..998157bb6a 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 @@ -103,12 +103,12 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto ); const relatedCollectionName = relatedData[0].collectionName; - const suggestedRecordId = relatedData[bestIndex].recordId; + const selectedRecordId = relatedData[bestIndex].recordId; await this.context.runStore.saveStepExecution(this.context.runId, { type: 'load-related-record', stepIndex: this.context.stepIndex, - pendingData: { displayName, name, relatedCollectionName, suggestedFields, suggestedRecordId }, + pendingData: { displayName, name, relatedCollectionName, suggestedFields, selectedRecordId }, selectedRecordRef, }); @@ -126,7 +126,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto } /** - * Branch A: builds RecordRef from pendingData suggestion or user's selectedRecordId override. + * Branch A: builds RecordRef from pendingData.selectedRecordId. * No additional getRelatedData call. */ private async resolveFromSelection( @@ -138,12 +138,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecuto throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); } - const { name, displayName, relatedCollectionName, suggestedRecordId, selectedRecordId } = - pendingData; + const { name, displayName, relatedCollectionName, selectedRecordId } = pendingData; const record: RecordRef = { collectionName: relatedCollectionName, - recordId: selectedRecordId ?? suggestedRecordId, + recordId: selectedRecordId, stepIndex: this.context.stepIndex, }; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 45fa00b76d..edd5f8df02 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -115,13 +115,11 @@ export interface LoadRelatedRecordPendingData extends RelationRef { relatedCollectionName: string; /** AI-selected fields suggested for display on the frontend. undefined = not computed (no non-relation fields). */ suggestedFields?: string[]; - /** AI's best pick from the 50 candidates — proposed to the user as default. */ - suggestedRecordId: Array; /** - * Record id chosen by the user. Written by the HTTP endpoint (dedicated ticket, not yet implemented). - * Falls back to suggestedRecordId when absent. + * The record id to load. Initially set by the AI; overwritten by the frontend via + * PATCH /runs/:runId/steps/:stepIndex/pending-data (not yet implemented). */ - selectedRecordId?: Array; + selectedRecordId: Array; } export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { 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 573cd00ecd..46910f72db 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 @@ -133,7 +133,7 @@ function makePendingExecution( displayName: 'Order', name: 'order', relatedCollectionName: 'orders', - suggestedRecordId: [99], + selectedRecordId: [99], suggestedFields: ['status', 'amount'], }, selectedRecordRef: makeRecordRef(), @@ -589,7 +589,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', name: 'order', relatedCollectionName: 'orders', - suggestedRecordId: [99], + selectedRecordId: [99], suggestedFields: [], }, selectedRecordRef: expect.objectContaining({ @@ -662,7 +662,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', name: 'order', relatedCollectionName: 'orders', - suggestedRecordId: [2], // record at index 1 + selectedRecordId: [2], // record at index 1 suggestedFields: ['status'], }, }), @@ -726,7 +726,7 @@ describe('LoadRelatedRecordStepExecutor', () => { 'run-1', expect.objectContaining({ pendingData: expect.objectContaining({ - suggestedRecordId: [1], + selectedRecordId: [1], suggestedFields: [], }), }), @@ -735,9 +735,9 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('confirmation accepted (Branch A)', () => { - it('uses suggestedRecordId when selectedRecordId is absent, no getRelatedData call', async () => { + it('uses selectedRecordId from pendingData, no getRelatedData call', async () => { const agentPort = makeMockAgentPort(); - const execution = makePendingExecution(); // suggestedRecordId: [99], no selectedRecordId + const execution = makePendingExecution(); // selectedRecordId: [99] const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), }); @@ -760,20 +760,19 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', name: 'order', relatedCollectionName: 'orders', - suggestedRecordId: [99], + selectedRecordId: [99], }), }), ); }); - it('uses selectedRecordId over suggestedRecordId when the user overrides the suggestion', async () => { + it('uses selectedRecordId when the user overrides the AI suggestion', async () => { const agentPort = makeMockAgentPort(); const execution = makePendingExecution({ pendingData: { displayName: 'Order', name: 'order', relatedCollectionName: 'orders', - suggestedRecordId: [99], suggestedFields: ['status', 'amount'], selectedRecordId: [42], }, @@ -1223,7 +1222,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Invoice', name: 'invoice', relatedCollectionName: 'invoices', - suggestedRecordId: [55], + selectedRecordId: [55], }), selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders' }), }), @@ -1419,7 +1418,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Invoice', name: 'invoice', relatedCollectionName: 'invoices', - suggestedRecordId: [55], + selectedRecordId: [55], }, }; diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts index 52bcbce4b9..dc4f65db29 100644 --- a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -41,7 +41,7 @@ describe('StepExecutionFormatters', () => { displayName: 'Address', name: 'address', relatedCollectionName: 'addresses', - suggestedRecordId: [1], + selectedRecordId: [1], }, }; diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts index 27b3308365..a6a5c743f4 100644 --- a/packages/workflow-executor/test/executors/step-summary-builder.test.ts +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -253,7 +253,7 @@ describe('StepSummaryBuilder', () => { displayName: 'Address', name: 'address', relatedCollectionName: 'addresses', - suggestedRecordId: [1], + selectedRecordId: [1], }, }; From a5d6249ca0f56797e4accf6a40b1f16da0d575e1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 24 Mar 2026 10:43:12 +0100 Subject: [PATCH 34/34] docs(workflow-executor): replace invented route names with TODOs in contract doc Routes for the pending-data write endpoint are not yet defined (PRD-240). Replace the made-up PATCH route with explicit TODO markers throughout. Co-Authored-By: Claude Sonnet 4.6 --- WORKFLOW-EXECUTOR-CONTRACT.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/WORKFLOW-EXECUTOR-CONTRACT.md b/WORKFLOW-EXECUTOR-CONTRACT.md index 5fc24d0da0..1313a0b13f 100644 --- a/WORKFLOW-EXECUTOR-CONTRACT.md +++ b/WORKFLOW-EXECUTOR-CONTRACT.md @@ -134,12 +134,16 @@ After executing a step, the executor posts the outcome back to the server. The b ## 3. Pending Data -Steps that require user input pause with `status: "awaiting-input"`. The frontend writes `pendingData` to unblock them, via a dedicated endpoint on the executor HTTP server (route TBD — PRD-240). +Steps that require user input pause with `status: "awaiting-input"`. The frontend writes `pendingData` to unblock them via a dedicated endpoint on the executor HTTP server. + +> **TODO** — The pending-data write endpoint is not yet implemented. Route, method, and per-step-type body shapes are TBD (PRD-240). Once written, the frontend calls `POST /runs/:runId/trigger` and the executor resumes with `userConfirmed: true`. ### update-record — user picks a field + value to write +> **TODO** — Pending-data write endpoint TBD (PRD-240). + ```typescript interface UpdateRecordPendingData { name: string; // technical field name @@ -160,26 +164,24 @@ POST /runs/:runId/trigger The frontend can override **both** the relation (field) and the selected record. -> **Current status** — The frontend cannot yet override the AI selection. The executor HTTP server does not yet expose the PATCH endpoint described below. Until it is implemented, the executor writes the AI's pick directly into `selectedRecordId`. +> **Current status** — The frontend cannot yet override the AI selection. The executor HTTP server does not yet expose the pending-data write endpoint. Until it is implemented, the executor writes the AI's pick directly into `selectedRecordId`. ```typescript -// Written by the executor and overwritable by the frontend via PATCH (not yet implemented) +// Written by the executor; overwritable by the frontend via the pending-data endpoint (TBD) interface LoadRelatedRecordPendingData { name: string; // technical relation name displayName: string; // label shown in the UI relatedCollectionName: string; // collection of the related record suggestedFields?: string[]; // fields suggested for display - selectedRecordId: Array; // AI's pick; overwritten by the frontend via PATCH + selectedRecordId: Array; // AI's pick; overwritten by the frontend via the pending-data endpoint } ``` -The executor initially writes the AI's pick into `selectedRecordId`. The PATCH endpoint overwrites it (and optionally `name`, `displayName`, `relatedCollectionName`) when the user changes the selection. +The executor initially writes the AI's pick into `selectedRecordId`. The pending-data endpoint overwrites it (and optionally `name`, `displayName`, `relatedCollectionName`) when the user changes the selection. -#### Future endpoint — PATCH pending data (not yet implemented) +#### Future endpoint — pending-data write (not yet implemented) -``` -PATCH /runs/:runId/steps/:stepIndex/pending-data -``` +> **TODO** — Route and method TBD (PRD-240). Request body: @@ -221,7 +223,7 @@ Orchestrator ──► GET pending?runId=X ──► Executor status: awaiting-input POST /complete │ (StepOutcome) Frontend writes pendingData - to executor HTTP server + to executor HTTP server TODO: route TBD │ POST /runs/:runId/trigger (next poll: userConfirmed = true)