From 9432bb1ac7f5491576e4e1969c0f7e5685e89574 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 11:41:24 +0100 Subject: [PATCH 01/56] feat(workflow-executor): add UpdateRecordStepExecutor with confirmation flow Implements PRD-219. Adds an executor for update-record steps with three execution branches: automatic completion, awaiting user confirmation, and re-entry after confirmation. Extracts shared record selection helpers from ReadRecordStepExecutor into BaseStepExecutor for reuse. Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/CLAUDE.md | 5 +- packages/workflow-executor/src/errors.ts | 6 + .../src/executors/base-step-executor.ts | 94 ++- .../executors/read-record-step-executor.ts | 84 +-- .../executors/update-record-step-executor.ts | 281 +++++++++ packages/workflow-executor/src/index.ts | 3 + .../workflow-executor/src/types/execution.ts | 1 + .../src/types/step-execution-data.ts | 15 + .../update-record-step-executor.test.ts | 578 ++++++++++++++++++ 9 files changed, 979 insertions(+), 88 deletions(-) create mode 100644 packages/workflow-executor/src/executors/update-record-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/update-record-step-executor.test.ts diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 333bfdee1..f0318c97b 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 +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError ├── 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 @@ -60,7 +60,8 @@ src/ ├── executors/ # Step executor implementations │ ├── 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 +│ ├── 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) ├── 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/errors.ts b/packages/workflow-executor/src/errors.ts index b835c391f..8b872a893 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -45,3 +45,9 @@ export class NoResolvedFieldsError extends WorkflowExecutorError { super(`None of the requested fields could be resolved: ${fieldNames.join(', ')}`); } } + +export class NoWritableFieldsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super(`No writable fields on record from 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 2197843be..b06ab9223 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,18 +1,30 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; -import type { StepExecutionData } from '../types/step-execution-data'; +import type { + LoadRelatedRecordStepExecutionData, + StepExecutionData, +} from '../types/step-execution-data'; import type { StepOutcome } from '../types/step-outcome'; import type { AIMessage, BaseMessage } from '@langchain/core/messages'; -import type { DynamicStructuredTool } from '@langchain/core/tools'; -import { SystemMessage } from '@langchain/core/messages'; +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; -import { MalformedToolCallError, MissingToolCallError } from '../errors'; +import { + MalformedToolCallError, + MissingToolCallError, + NoRecordsError, + WorkflowExecutorError, +} from '../errors'; import { isExecutedStepOnExecutor } from '../types/step-execution-data'; export default abstract class BaseStepExecutor { protected readonly context: ExecutionContext; + protected readonly schemaCache = new Map(); + constructor(context: ExecutionContext) { this.context = context; } @@ -107,4 +119,78 @@ export default abstract class BaseStepExecutor { + const stepExecutions = await this.context.runStore.getStepExecutions(); + const relatedRecords = stepExecutions + .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') + .map(e => e.record); + + return [this.context.baseRecordRef, ...relatedRecords]; + } + + /** Selects a record ref via AI when multiple are available, returns directly when only one. */ + protected async selectRecordRef( + records: RecordRef[], + prompt: string | undefined, + ): Promise { + if (records.length === 0) throw new NoRecordsError(); + if (records.length === 1) return records[0]; + + const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); + const identifierTuple = identifiers as [string, ...string[]]; + + const tool = new DynamicStructuredTool({ + name: 'select-record', + description: 'Select the most relevant record for this workflow step.', + schema: z.object({ + recordIdentifier: z.enum(identifierTuple), + }), + func: undefined, + }); + + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage( + 'You are an AI agent selecting the most relevant record for a workflow step.\n' + + 'Choose the record whose collection best matches the user request.\n' + + 'Pay attention to the collection name of each record.', + ), + new HumanMessage(prompt ?? 'Select the most relevant record.'), + ]; + + const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( + messages, + tool, + ); + + const selectedIndex = identifiers.indexOf(recordIdentifier); + + if (selectedIndex === -1) { + throw new WorkflowExecutorError( + `AI selected record "${recordIdentifier}" which does not match any available record`, + ); + } + + return records[selectedIndex]; + } + + /** Fetches a collection schema from WorkflowPort, with caching. */ + protected async getCollectionSchema(collectionName: string): Promise { + const cached = this.schemaCache.get(collectionName); + if (cached) return cached; + + const schema = await this.context.workflowPort.getCollectionSchema(collectionName); + this.schemaCache.set(collectionName, schema); + + return schema; + } + + /** Formats a record ref as "Step X - CollectionDisplayName #id". */ + protected async toRecordIdentifier(record: RecordRef): Promise { + const schema = await this.getCollectionSchema(record.collectionName); + + return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; + } } 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 6f7248c3c..abff85a7e 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,21 +1,13 @@ import type { StepExecutionResult } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; -import type { - FieldReadResult, - LoadRelatedRecordStepExecutionData, -} from '../types/step-execution-data'; +import type { FieldReadResult } from '../types/step-execution-data'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -import { - NoReadableFieldsError, - NoRecordsError, - NoResolvedFieldsError, - WorkflowExecutorError, -} from '../errors'; +import { NoReadableFieldsError, NoResolvedFieldsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. @@ -27,8 +19,6 @@ Important rules: - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; export default class ReadRecordStepExecutor extends BaseStepExecutor { - private readonly schemaCache = new Map(); - async execute(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); @@ -111,51 +101,6 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - if (records.length === 0) throw new NoRecordsError(); - if (records.length === 1) return records[0]; - - const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); - const identifierTuple = identifiers as [string, ...string[]]; - - const tool = new DynamicStructuredTool({ - name: 'select-record', - description: 'Select the most relevant record for this workflow step.', - schema: z.object({ - recordIdentifier: z.enum(identifierTuple), - }), - func: undefined, - }); - - const messages = [ - ...(await this.buildPreviousStepsMessages()), - new SystemMessage( - 'You are an AI agent selecting the most relevant record for a workflow step.\n' + - 'Choose the record whose collection best matches the user request.\n' + - 'Pay attention to the collection name of each record.', - ), - new HumanMessage(prompt ?? 'Select the most relevant record.'), - ]; - - const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( - messages, - tool, - ); - - const selectedIndex = identifiers.indexOf(recordIdentifier); - - if (selectedIndex === -1) { - throw new WorkflowExecutorError( - `AI selected record "${recordIdentifier}" which does not match any available record`, - ); - } - - return records[selectedIndex]; - } - private buildReadFieldTool(schema: CollectionSchema): DynamicStructuredTool { const nonRelationFields = schema.fields.filter(f => !f.isRelationship); @@ -201,29 +146,4 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const relatedRecords = stepExecutions - .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') - .map(e => e.record); - - return [this.context.baseRecordRef, ...relatedRecords]; - } - - private async getCollectionSchema(collectionName: string): Promise { - const cached = this.schemaCache.get(collectionName); - if (cached) return cached; - - const schema = await this.context.workflowPort.getCollectionSchema(collectionName); - this.schemaCache.set(collectionName, schema); - - return schema; - } - - private async toRecordIdentifier(record: RecordRef): Promise { - const schema = await this.getCollectionSchema(record.collectionName); - - return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.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 new file mode 100644 index 000000000..796fc8320 --- /dev/null +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -0,0 +1,281 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, RecordRef } from '../types/record'; +import type { AiTaskStepDefinition } from '../types/step-definition'; +import type { UpdateRecordStepExecutionData } from '../types/step-execution-data'; + +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { NoWritableFieldsError, WorkflowExecutorError } from '../errors'; +import BaseStepExecutor from './base-step-executor'; + +const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. +Select the field to update and provide the new value. + +Important rules: +- Be precise: only update the field that is directly relevant to the request. +- 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.`; + +export default class UpdateRecordStepExecutor extends BaseStepExecutor { + async execute(): Promise { + // Branch A — Re-entry with user confirmation + if (this.context.userInput) { + return this.handleConfirmation(); + } + + // Branches B & C — First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(); + const interruption = stepExecutions.find( + (e): e is UpdateRecordStepExecutionData => + e.type === 'update-record' && + e.stepIndex === this.context.stepIndex && + !!e.toolConfirmationInterruption, + ); + + if (!interruption) { + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'error', + error: 'No pending confirmation found for this step', + }, + }; + } + + const { confirmed } = this.context.userInput as { confirmed: boolean }; + + if (!confirmed) { + await this.context.runStore.saveStepExecution({ + ...interruption, + toolConfirmationInterruption: undefined, + executionResult: { + skipped: true, + } as unknown as UpdateRecordStepExecutionData['executionResult'], + }); + + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'success', + }, + }; + } + + // User confirmed — resolve and update + const { selectedRecordRef, toolConfirmationInterruption } = interruption; + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const { fieldDisplayName, value } = toolConfirmationInterruption as { + fieldDisplayName: string; + value: string; + }; + + const fieldName = this.resolveFieldName(schema, fieldDisplayName); + + try { + const updated = await this.context.agentPort.updateRecord( + selectedRecordRef.collectionName, + selectedRecordRef.recordId, + { [fieldName]: value }, + ); + + await this.context.runStore.saveStepExecution({ + ...interruption, + toolConfirmationInterruption: undefined, + executionParams: { fieldName: fieldDisplayName, value }, + executionResult: { updatedValues: updated.values }, + }); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'error', + error: error.message, + }, + }; + } + + throw error; + } + + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'success', + }, + }; + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + + let selectedRecordRef: RecordRef; + let schema: CollectionSchema; + let fieldDisplayName: string; + let value: string; + + try { + selectedRecordRef = await this.selectRecordRef(records, step.prompt); + schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectFieldAndValue(schema, step.prompt); + fieldDisplayName = args.fieldName; + value = args.value; + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'error', + error: error.message, + }, + }; + } + + throw error; + } + + // Branch B — automaticCompletion + if (step.automaticCompletion) { + return this.executeUpdate(selectedRecordRef, schema, fieldDisplayName, value); + } + + // Branch C — Awaiting confirmation + await this.context.runStore.saveStepExecution({ + type: 'update-record', + stepIndex: this.context.stepIndex, + toolConfirmationInterruption: { fieldDisplayName, value }, + selectedRecordRef, + }); + + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'awaiting-input', + }, + }; + } + + private async executeUpdate( + selectedRecordRef: RecordRef, + schema: CollectionSchema, + fieldDisplayName: string, + value: string, + ): Promise { + const fieldName = this.resolveFieldName(schema, fieldDisplayName); + + try { + const updated = await this.context.agentPort.updateRecord( + selectedRecordRef.collectionName, + selectedRecordRef.recordId, + { [fieldName]: value }, + ); + + await this.context.runStore.saveStepExecution({ + type: 'update-record', + stepIndex: this.context.stepIndex, + executionParams: { fieldName: fieldDisplayName, value }, + executionResult: { updatedValues: updated.values }, + selectedRecordRef, + }); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'error', + error: error.message, + }, + }; + } + + throw error; + } + + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: 'success', + }, + }; + } + + private async selectFieldAndValue( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ fieldName: string; value: string; reasoning: string }> { + const tool = this.buildUpdateFieldTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(UPDATE_RECORD_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Update the relevant field.'}`), + ]; + + return this.invokeWithTool<{ fieldName: string; value: string; reasoning: string }>( + messages, + tool, + ); + } + + private buildUpdateFieldTool(schema: CollectionSchema): DynamicStructuredTool { + const nonRelationFields = schema.fields.filter(f => !f.isRelationship); + + if (nonRelationFields.length === 0) { + throw new NoWritableFieldsError(schema.collectionName); + } + + const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; + + return new DynamicStructuredTool({ + name: 'update-record-field', + description: 'Update a field on the selected record.', + schema: z.object({ + fieldName: z.enum(displayNames), + // z.string() intentionally: the value is always transmitted as string + // to updateRecord; data typing is handled by the agent/datasource layer. + value: z.string().describe('The new value for the field'), + reasoning: z.string().describe('Why this field and value were chosen'), + }), + func: undefined, + }); + } + + private resolveFieldName(schema: CollectionSchema, displayName: string): string { + const field = schema.fields.find( + f => f.displayName === displayName || f.fieldName === displayName, + ); + + if (!field) { + throw new WorkflowExecutorError( + `Field "${displayName}" not found in collection "${schema.collectionName}"`, + ); + } + + return field.fieldName; + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 916bbc075..1b7aeab83 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -18,6 +18,7 @@ export type { FieldReadResult, ConditionStepExecutionData, ReadRecordStepExecutionData, + UpdateRecordStepExecutionData, AiTaskStepExecutionData, LoadRelatedRecordStepExecutionData, ExecutedStepExecutionData, @@ -54,10 +55,12 @@ export { NoRecordsError, NoReadableFieldsError, NoResolvedFieldsError, + NoWritableFieldsError, } 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 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/execution.ts b/packages/workflow-executor/src/types/execution.ts index 406d1e4f0..6f379d442 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -41,4 +41,5 @@ export interface ExecutionContext readonly runStore: RunStore; readonly history: ReadonlyArray>; readonly remoteTools: readonly unknown[]; + readonly userInput?: UserInput; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index eb022a273..2ddff36a1 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -40,6 +40,19 @@ export interface ReadRecordStepExecutionData extends BaseStepExecutionData { selectedRecordRef: RecordRef; } +// -- Update Record -- + +export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { + type: 'update-record'; + executionParams?: { fieldName: string; value: string }; + executionResult?: { updatedValues: Record }; + toolConfirmationInterruption?: { + fieldDisplayName: string; + value: string; + }; + selectedRecordRef: RecordRef; +} + // -- Generic AI Task (fallback for untyped steps) -- export interface AiTaskStepExecutionData extends BaseStepExecutionData { @@ -61,12 +74,14 @@ export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionDat export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData + | UpdateRecordStepExecutionData | AiTaskStepExecutionData | LoadRelatedRecordStepExecutionData; export type ExecutedStepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData + | UpdateRecordStepExecutionData | AiTaskStepExecutionData; // TODO: this condition should change when load-related-record gets its own executor 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 new file mode 100644 index 000000000..fef81d313 --- /dev/null +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -0,0 +1,578 @@ +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, UserInput } from '../../src/types/execution'; +import type { CollectionSchema, RecordRef } from '../../src/types/record'; +import type { AiTaskStepDefinition } from '../../src/types/step-definition'; +import type { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; + +import { WorkflowExecutorError } from '../../src/errors'; +import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): AiTaskStepDefinition { + return { + type: StepType.UpdateRecord, + prompt: 'Set the customer status to active', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeMockAgentPort( + updatedValues: Record = { status: 'active', name: 'John Doe' }, +): AgentPort { + return { + getRecord: jest.fn().mockResolvedValue({ values: updatedValues }), + updateRecord: jest.fn().mockResolvedValue({ + collectionName: 'customers', + recordId: [42], + values: updatedValues, + }), + getRelatedData: jest.fn(), + executeAction: jest.fn(), + } 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 }, + { fieldName: 'name', displayName: 'Full Name', isRelationship: false }, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true }, + ], + 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 = 'update-record-field') { + 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: 'update-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + history: [], + remoteTools: [], + ...overrides, + }; +} + +describe('UpdateRecordStepExecutor', () => { + describe('automaticCompletion: update direct (Branch B)', () => { + it('updates the record and returns success', async () => { + const updatedValues = { status: 'active', name: 'John Doe' }; + const agentPort = makeMockAgentPort(updatedValues); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'update-record', + stepIndex: 0, + executionParams: { fieldName: 'Status', value: 'active' }, + executionResult: { updatedValues }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('without automaticCompletion: awaiting-input (Branch C)', () => { + it('saves interruption and returns awaiting-input', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'update-record', + stepIndex: 0, + toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('updates the record when user confirms', async () => { + const updatedValues = { status: 'active', name: 'John Doe' }; + const agentPort = makeMockAgentPort(updatedValues); + const interruption: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([interruption]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ agentPort, runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'update-record', + executionParams: { fieldName: 'Status', value: 'active' }, + executionResult: { updatedValues }, + toolConfirmationInterruption: undefined, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the update when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const interruption: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([interruption]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: false }; + const context = makeContext({ agentPort, runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionResult: { skipped: true }, + toolConfirmationInterruption: undefined, + }), + ); + }); + }); + + describe('no interruption in phase 2 (Branch A)', () => { + it('returns error when no pending confirmation is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No pending confirmation found for this step'); + }); + }); + + describe('multi-record AI selection', () => { + it('uses AI to select among multiple records then selects field', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', isRelationship: false }, + { fieldName: 'status', displayName: 'Order Status', isRelationship: false }, + ], + }); + + // First call: select-record, second call: update-record-field + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'update-record-field', + args: { fieldName: 'Order Status', value: 'shipped', reasoning: 'Mark as shipped' }, + 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 context = makeContext({ baseRecordRef, model, runStore, workflowPort }); + const executor = new UpdateRecordStepExecutor(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 updateTool = bindTools.mock.calls[1][0][0]; + expect(updateTool.name).toBe('update-record-field'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + toolConfirmationInterruption: { fieldDisplayName: 'Order Status', value: 'shipped' }, + selectedRecordRef: expect.objectContaining({ + recordId: [99], + collectionName: 'orders', + }), + }), + ); + }); + }); + + describe('NoWritableFieldsError', () => { + it('returns error when all fields are relationships', async () => { + const schema = makeCollectionSchema({ + fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], + }); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'No writable fields on record from collection "customers"', + ); + expect(runStore.saveStepExecution).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: 'update-record-field', 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 UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'AI returned a malformed tool call for "update-record-field": 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 UpdateRecordStepExecutor(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('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'), + ); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Record locked'); + }); + }); + + 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'), + ); + const interruption: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([interruption]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ agentPort, runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Record locked'); + }); + }); + + describe('agentPort.updateRecord infra error', () => { + it('lets infrastructure errors propagate (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + + it('lets infrastructure errors propagate (Branch A)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const interruption: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([interruption]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ agentPort, runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Connection refused'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Update the relevant field.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in update-field messages', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + 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, + history: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new UpdateRecordStepExecutor({ + ...context, + stepId: 'update-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps summary + 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('updating a field on a record'); + }); + }); +}); From a70b36a0b5a05f613a911d1a7667e944ceaf66f4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 11:47:09 +0100 Subject: [PATCH 02/56] refactor(workflow-executor): simplify interruption lookup in UpdateRecordStepExecutor Match on type + stepIndex only, then guard on toolConfirmationInterruption presence separately. Removes redundant filter predicate. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) 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 796fc8320..51c73a21c 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -33,12 +33,10 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor - e.type === 'update-record' && - e.stepIndex === this.context.stepIndex && - !!e.toolConfirmationInterruption, + e.type === 'update-record' && e.stepIndex === this.context.stepIndex, ); - if (!interruption) { + if (!interruption?.toolConfirmationInterruption) { return { stepOutcome: { type: 'ai-task', @@ -74,10 +72,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor Date: Thu, 19 Mar 2026 11:57:39 +0100 Subject: [PATCH 03/56] refactor(workflow-executor): rename toolConfirmationInterruption to pendingUpdate The field stores the proposed field update (from AI or user-edited) pending confirmation. "pendingUpdate" better describes the content than "toolConfirmationInterruption" which described the mechanism. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 12 ++++++------ .../src/types/step-execution-data.ts | 2 +- .../update-record-step-executor.test.ts | 16 ++++++++-------- 3 files changed, 15 insertions(+), 15 deletions(-) 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 51c73a21c..ba4b8f7f7 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -36,7 +36,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor }; - toolConfirmationInterruption?: { + pendingUpdate?: { fieldDisplayName: string; value: string; }; 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 fef81d313..ba84ba602 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 @@ -177,7 +177,7 @@ describe('UpdateRecordStepExecutor', () => { expect.objectContaining({ type: 'update-record', stepIndex: 0, - toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', recordId: [42], @@ -194,7 +194,7 @@ describe('UpdateRecordStepExecutor', () => { const interruption: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -213,7 +213,7 @@ describe('UpdateRecordStepExecutor', () => { type: 'update-record', executionParams: { fieldName: 'Status', value: 'active' }, executionResult: { updatedValues }, - toolConfirmationInterruption: undefined, + pendingUpdate: undefined, }), ); }); @@ -225,7 +225,7 @@ describe('UpdateRecordStepExecutor', () => { const interruption: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -242,7 +242,7 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionResult: { skipped: true }, - toolConfirmationInterruption: undefined, + pendingUpdate: undefined, }), ); }); @@ -333,7 +333,7 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ - toolConfirmationInterruption: { fieldDisplayName: 'Order Status', value: 'shipped' }, + pendingUpdate: { fieldDisplayName: 'Order Status', value: 'shipped' }, selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', @@ -447,7 +447,7 @@ describe('UpdateRecordStepExecutor', () => { const interruption: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ @@ -489,7 +489,7 @@ describe('UpdateRecordStepExecutor', () => { const interruption: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, - toolConfirmationInterruption: { fieldDisplayName: 'Status', value: 'active' }, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ From 545bbd0f232efd9b33888ecb99c45d7684aa5822 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 12:01:10 +0100 Subject: [PATCH 04/56] refactor(workflow-executor): throw on missing pendingUpdate instead of error outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing pendingUpdate in Branch A is a bug (orchestrator/RunStore), not a business case — throw WorkflowExecutorError instead of returning a graceful error outcome. Also rename local variable interruption → execution. Co-Authored-By: Claude Opus 4.6 --- .../executors/update-record-step-executor.ts | 20 +++++--------- .../update-record-step-executor.test.ts | 27 +++++++++---------- 2 files changed, 18 insertions(+), 29 deletions(-) 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 ba4b8f7f7..7eefe9375 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -31,28 +31,20 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { const stepExecutions = await this.context.runStore.getStepExecutions(); - const interruption = stepExecutions.find( + const execution = stepExecutions.find( (e): e is UpdateRecordStepExecutionData => e.type === 'update-record' && e.stepIndex === this.context.stepIndex, ); - if (!interruption?.pendingUpdate) { - return { - stepOutcome: { - type: 'ai-task', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'error', - error: 'No pending confirmation found for this step', - }, - }; + if (!execution?.pendingUpdate) { + throw new WorkflowExecutorError('No pending update found for this step'); } const { confirmed } = this.context.userInput as { confirmed: boolean }; if (!confirmed) { await this.context.runStore.saveStepExecution({ - ...interruption, + ...execution, pendingUpdate: undefined, executionResult: { skipped: true, @@ -70,7 +62,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { }); describe('without automaticCompletion: awaiting-input (Branch C)', () => { - it('saves interruption and returns awaiting-input', async () => { + it('saves execution and returns awaiting-input', async () => { const mockModel = makeMockModel({ fieldName: 'Status', value: 'active', @@ -191,14 +191,14 @@ describe('UpdateRecordStepExecutor', () => { it('updates the record when user confirms', async () => { const updatedValues = { status: 'active', name: 'John Doe' }; const agentPort = makeMockAgentPort(updatedValues); - const interruption: UpdateRecordStepExecutionData = { + const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([interruption]), + getStepExecutions: jest.fn().mockResolvedValue([execution]), }); const userInput: UserInput = { type: 'confirmation', confirmed: true }; const context = makeContext({ agentPort, runStore, userInput }); @@ -222,14 +222,14 @@ describe('UpdateRecordStepExecutor', () => { describe('confirmation rejected (Branch A)', () => { it('skips the update when user rejects', async () => { const agentPort = makeMockAgentPort(); - const interruption: UpdateRecordStepExecutionData = { + const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([interruption]), + getStepExecutions: jest.fn().mockResolvedValue([execution]), }); const userInput: UserInput = { type: 'confirmation', confirmed: false }; const context = makeContext({ agentPort, runStore, userInput }); @@ -248,8 +248,8 @@ describe('UpdateRecordStepExecutor', () => { }); }); - describe('no interruption in phase 2 (Branch A)', () => { - it('returns error when no pending confirmation is found', async () => { + describe('no pending update in phase 2 (Branch A)', () => { + it('throws when no pending update is found', async () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]), }); @@ -257,10 +257,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ runStore, userInput }); const executor = new UpdateRecordStepExecutor(context); - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('No pending confirmation found for this step'); + await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); }); }); @@ -444,14 +441,14 @@ describe('UpdateRecordStepExecutor', () => { (agentPort.updateRecord as jest.Mock).mockRejectedValue( new WorkflowExecutorError('Record locked'), ); - const interruption: UpdateRecordStepExecutionData = { + const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([interruption]), + getStepExecutions: jest.fn().mockResolvedValue([execution]), }); const userInput: UserInput = { type: 'confirmation', confirmed: true }; const context = makeContext({ agentPort, runStore, userInput }); @@ -486,14 +483,14 @@ describe('UpdateRecordStepExecutor', () => { it('lets infrastructure errors propagate (Branch A)', async () => { const agentPort = makeMockAgentPort(); (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const interruption: UpdateRecordStepExecutionData = { + const execution: UpdateRecordStepExecutionData = { type: 'update-record', stepIndex: 0, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, selectedRecordRef: makeRecordRef(), }; const runStore = makeMockRunStore({ - getStepExecutions: jest.fn().mockResolvedValue([interruption]), + getStepExecutions: jest.fn().mockResolvedValue([execution]), }); const userInput: UserInput = { type: 'confirmation', confirmed: true }; const context = makeContext({ agentPort, runStore, userInput }); From ebf033f20ff8c11cd141aa1bcada3ec90ed94083 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 12:04:20 +0100 Subject: [PATCH 05/56] refactor(workflow-executor): type executionResult as union instead of casting Add { skipped: true } to the executionResult union type, removing the unsafe `as unknown as` cast when the user rejects the update. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 4 +--- packages/workflow-executor/src/types/step-execution-data.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) 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 7eefe9375..305e81743 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -46,9 +46,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor }; + executionResult?: { updatedValues: Record } | { skipped: true }; pendingUpdate?: { fieldDisplayName: string; value: string; From 61e2cb0db5e42e4e21e49272b7ab9a058534d2de Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 12:06:45 +0100 Subject: [PATCH 06/56] fix(workflow-executor): preserve pendingUpdate after confirmation for traceability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep pendingUpdate in the saved execution data after both confirm and reject — useful for audit trail (what was proposed vs what happened). Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 2 -- .../test/executors/update-record-step-executor.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) 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 305e81743..bc03cdc44 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -45,7 +45,6 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { type: 'update-record', executionParams: { fieldName: 'Status', value: 'active' }, executionResult: { updatedValues }, - pendingUpdate: undefined, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, }), ); }); @@ -242,7 +242,7 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionResult: { skipped: true }, - pendingUpdate: undefined, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, }), ); }); From 31a27c6c023ef351c1d18356264d426364814c06 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 12:23:21 +0100 Subject: [PATCH 07/56] refactor(workflow-executor): remove unsafe cast in UpdateRecordStepExecutor Pass userInput as parameter to handleConfirmation() where it is already narrowed, removing the `as { confirmed: boolean }` cast. Also move resolveFieldName inside try-catch blocks so WorkflowExecutorError is properly caught and returned as a status: 'error' outcome. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) 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 bc03cdc44..9fb2d821a 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -1,4 +1,4 @@ -import type { StepExecutionResult } from '../types/execution'; +import type { StepExecutionResult, UserInput } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; import type { UpdateRecordStepExecutionData } from '../types/step-execution-data'; @@ -22,14 +22,14 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { // Branch A — Re-entry with user confirmation if (this.context.userInput) { - return this.handleConfirmation(); + return this.handleConfirmation(this.context.userInput); } // Branches B & C — First call return this.handleFirstCall(); } - private async handleConfirmation(): Promise { + private async handleConfirmation(userInput: UserInput): Promise { const stepExecutions = await this.context.runStore.getStepExecutions(); const execution = stepExecutions.find( (e): e is UpdateRecordStepExecutionData => @@ -40,7 +40,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { - const fieldName = this.resolveFieldName(schema, fieldDisplayName); - try { + const fieldName = this.resolveFieldName(schema, fieldDisplayName); const updated = await this.context.agentPort.updateRecord( selectedRecordRef.collectionName, selectedRecordRef.recordId, From f7a3edcf3dea5b0000a8460c2e7561f1a8bf46f7 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 14:14:44 +0100 Subject: [PATCH 08/56] refactor(workflow-executor): simplify UpdateRecordStepExecutor and improve coverage - Extract resolveAndUpdate to deduplicate resolve+update+persist logic - Extract buildOutcomeResult/buildSuccessResult/buildErrorResult helpers - Move getCollectionSchema inside try-catch in confirmation flow - Rename executionParams.fieldName to fieldDisplayName for consistency - Add tests for resolveFieldName failure in both branches - Add test for relationship field exclusion from update tool schema Co-Authored-By: Claude Opus 4.6 --- .../executors/update-record-step-executor.ts | 132 ++++++------------ .../src/types/step-execution-data.ts | 2 +- .../update-record-step-executor.test.ts | 84 ++++++++++- 3 files changed, 127 insertions(+), 91 deletions(-) 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 9fb2d821a..751d331bf 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -20,12 +20,12 @@ Important rules: export default class UpdateRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { - // Branch A — Re-entry with user confirmation + // Branch A -- Re-entry with user confirmation if (this.context.userInput) { return this.handleConfirmation(this.context.userInput); } - // Branches B & C — First call + // Branches B & C -- First call return this.handleFirstCall(); } @@ -40,66 +40,23 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { @@ -107,38 +64,29 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { try { + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); const fieldName = this.resolveFieldName(schema, fieldDisplayName); const updated = await this.context.agentPort.updateRecord( selectedRecordRef.collectionName, @@ -171,38 +118,47 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor } | { skipped: true }; pendingUpdate?: { fieldDisplayName: string; 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 ebd2e70f1..68234af1f 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 @@ -145,7 +145,7 @@ describe('UpdateRecordStepExecutor', () => { expect.objectContaining({ type: 'update-record', stepIndex: 0, - executionParams: { fieldName: 'Status', value: 'active' }, + executionParams: { fieldDisplayName: 'Status', value: 'active' }, executionResult: { updatedValues }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', @@ -211,7 +211,7 @@ describe('UpdateRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ type: 'update-record', - executionParams: { fieldName: 'Status', value: 'active' }, + executionParams: { fieldDisplayName: 'Status', value: 'active' }, executionResult: { updatedValues }, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, }), @@ -365,6 +365,86 @@ 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 userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ runStore, workflowPort, userInput }); + 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 automaticCompletion (Branch B)', async () => { + // AI returns a display name that doesn't match any field in the schema + const mockModel = makeMockModel({ + fieldName: 'NonExistentField', + value: 'test', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + 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"', + ); + }); + }); + + describe('relationship fields excluded from update tool', () => { + it('excludes relationship fields from the tool schema', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ model: mockModel.model }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + // Second bindTools call is for update-record-field (first may be select-record) + const lastCall = mockModel.bindTools.mock.calls[mockModel.bindTools.mock.calls.length - 1]; + const tool = lastCall[0][0]; + expect(tool.name).toBe('update-record-field'); + + // Non-relationship display names should be accepted + expect(tool.schema.parse({ fieldName: 'Email', value: 'x', reasoning: 'r' })).toBeTruthy(); + expect(tool.schema.parse({ fieldName: 'Status', value: 'x', reasoning: 'r' })).toBeTruthy(); + expect( + tool.schema.parse({ fieldName: 'Full Name', value: 'x', reasoning: 'r' }), + ).toBeTruthy(); + + // Relationship display name should be rejected + expect(() => + tool.schema.parse({ fieldName: 'Orders', value: 'x', reasoning: 'r' }), + ).toThrow(); + }); + }); + describe('AI malformed/missing tool call', () => { it('returns error on malformed tool call', async () => { const invoke = jest.fn().mockResolvedValue({ From 981991700fc2bfeaae1e553ec7841dcc8f600447 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 14:29:33 +0100 Subject: [PATCH 09/56] refactor(workflow-executor): remove redundant buildSuccessResult/buildErrorResult wrappers Co-Authored-By: Claude Opus 4.6 --- .../src/executors/update-record-step-executor.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 751d331bf..7e2dfbf57 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -46,7 +46,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor Date: Thu, 19 Mar 2026 14:46:58 +0100 Subject: [PATCH 10/56] refactor(workflow-executor): rename history to previousSteps and document executionResult Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 4 ++-- .../workflow-executor/src/types/execution.ts | 2 +- .../src/types/step-execution-data.ts | 1 + .../test/executors/base-step-executor.test.ts | 22 +++++++++---------- .../executors/condition-step-executor.test.ts | 4 ++-- .../read-record-step-executor.test.ts | 4 ++-- .../update-record-step-executor.test.ts | 4 ++-- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index b06ab9223..82a6abea7 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -36,7 +36,7 @@ export default abstract class BaseStepExecutor { - if (!this.context.history.length) return []; + if (!this.context.previousSteps.length) return []; const summary = await this.summarizePreviousSteps(); @@ -52,7 +52,7 @@ export default abstract class BaseStepExecutor { const allStepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - return this.context.history + return this.context.previousSteps .map(({ stepDefinition, stepOutcome }) => { const execution = allStepExecutions.find(e => e.stepIndex === stepOutcome.stepIndex); diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 6f379d442..c69097b27 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -39,7 +39,7 @@ export interface ExecutionContext readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; readonly runStore: RunStore; - readonly history: ReadonlyArray>; + readonly previousSteps: ReadonlyArray>; readonly remoteTools: readonly unknown[]; readonly userInput?: UserInput; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index b99166856..cc81fdd78 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -45,6 +45,7 @@ export interface ReadRecordStepExecutionData extends BaseStepExecutionData { export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { type: 'update-record'; executionParams?: { fieldDisplayName: string; value: string }; + /** User confirmed → values returned by updateRecord. User rejected → skipped. */ executionResult?: { updatedValues: Record } | { skipped: true }; pendingUpdate?: { fieldDisplayName: string; 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 86491fbb8..9fff9ab99 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -73,7 +73,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), - history: [], + previousSteps: [], remoteTools: [], ...overrides, }; @@ -98,7 +98,7 @@ describe('BaseStepExecutor', () => { ]); const executor = new TestableExecutor( makeContext({ - history: [makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Approve?' })], + previousSteps: [makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Approve?' })], runStore, }), ); @@ -117,7 +117,7 @@ describe('BaseStepExecutor', () => { it('uses Input for matched steps and History for unmatched steps', async () => { const executor = new TestableExecutor( makeContext({ - history: [ + previousSteps: [ makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0 }), makeHistoryEntry({ stepId: 'cond-2', stepIndex: 1, prompt: 'Second?' }), ], @@ -147,7 +147,7 @@ describe('BaseStepExecutor', () => { it('falls back to History when no matching step execution in RunStore', async () => { const executor = new TestableExecutor( makeContext({ - history: [ + previousSteps: [ makeHistoryEntry({ stepId: 'orphan', stepIndex: 5, prompt: 'Orphan step' }), makeHistoryEntry({ stepId: 'matched', stepIndex: 1, prompt: 'Matched step' }), ], @@ -183,7 +183,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([]), }), ); @@ -207,7 +207,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([]), }), ); @@ -236,7 +236,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([]), }), ); @@ -272,7 +272,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [condEntry, aiEntry], + previousSteps: [condEntry, aiEntry], runStore: makeMockRunStore([ { type: 'ai-task', @@ -299,7 +299,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([ { type: 'condition', @@ -336,7 +336,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([ { type: 'ai-task', @@ -361,7 +361,7 @@ describe('BaseStepExecutor', () => { const executor = new TestableExecutor( makeContext({ - history: [entry], + previousSteps: [entry], runStore: makeMockRunStore([ { type: 'condition', 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 23eb6c836..3500dcce6 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -53,7 +53,7 @@ function makeContext( agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), - history: [], + previousSteps: [], remoteTools: [], ...overrides, }; @@ -175,7 +175,7 @@ describe('ConditionStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - history: [ + previousSteps: [ { stepDefinition: { type: StepType.Condition, 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 eb9c3bc5d..0f0863dc1 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 @@ -111,7 +111,7 @@ function makeContext( agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), - history: [], + previousSteps: [], remoteTools: [], ...overrides, }; @@ -700,7 +700,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - history: [ + previousSteps: [ { stepDefinition: { type: StepType.Condition, 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 68234af1f..0786f651f 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 @@ -112,7 +112,7 @@ function makeContext( agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), - history: [], + previousSteps: [], remoteTools: [], ...overrides, }; @@ -620,7 +620,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - history: [ + previousSteps: [ { stepDefinition: { type: StepType.Condition, From 239ca2f3a602e00b2de6d262a30a5f69e7b50c39 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 14:48:13 +0100 Subject: [PATCH 11/56] docs(workflow-executor): document pendingUpdate field on UpdateRecordStepExecutionData Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/src/types/step-execution-data.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index cc81fdd78..0d1459461 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -47,6 +47,7 @@ export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { executionParams?: { fieldDisplayName: string; value: string }; /** User confirmed → values returned by updateRecord. User rejected → skipped. */ executionResult?: { updatedValues: Record } | { skipped: true }; + /** AI-selected field and value awaiting user confirmation. Used in the confirmation flow only. */ pendingUpdate?: { fieldDisplayName: string; value: string; From 50905bc5e6a3a521067d2b380c6efd19806ba4da Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 15:48:39 +0100 Subject: [PATCH 12/56] refactor(workflow-executor): extract findField helper to deduplicate field resolution Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 8 +++----- .../src/executors/update-record-step-executor.ts | 5 ++--- packages/workflow-executor/src/index.ts | 1 + packages/workflow-executor/src/types/record.ts | 5 +++++ 4 files changed, 11 insertions(+), 8 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 abff85a7e..8b25bd363 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoResolvedFieldsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; +import { findField } from '../types/record'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. Select the field(s) that best answer the request. You can read one field or multiple fields at once. @@ -32,10 +33,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor - schema.fields.find(f => f.fieldName === name || f.displayName === name)?.fieldName, - ) + .map(name => findField(schema, name)?.fieldName) .filter((name): name is string => name !== undefined); if (resolvedFieldNames.length === 0) { @@ -135,7 +133,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); + const field = findField(schema, name); if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: 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 7e2dfbf57..ef5cab565 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { NoWritableFieldsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; +import { findField } from '../types/record'; const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. Select the field to update and provide the new value. @@ -195,9 +196,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor f.displayName === displayName || f.fieldName === displayName, - ); + const field = findField(schema, displayName); if (!field) { throw new WorkflowExecutorError( diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 1b7aeab83..95aa37cd2 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -34,6 +34,7 @@ export type { RecordRef, RecordData, } from './types/record'; +export { findField } from './types/record'; export type { Step, diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index b5070c39f..369a95e79 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -21,6 +21,11 @@ export interface CollectionSchema { actions: ActionSchema[]; } +/** Find a field by fieldName or displayName. */ +export function findField(schema: CollectionSchema, name: string): FieldSchema | undefined { + return schema.fields.find(f => f.fieldName === name || f.displayName === name); +} + // -- Record types (data — source: AgentPort/RunStore) -- /** Lightweight pointer to a specific record. */ From 129290c55d2ad9c31eba571372d73507c9c1c1c2 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 16:04:07 +0100 Subject: [PATCH 13/56] refactor(workflow-executor): apply PR review fixes across executors - Move findField to BaseStepExecutor with displayName priority over fieldName - Move buildOutcomeResult to BaseStepExecutor, use error !== undefined check - Fix resolveAndUpdate try-catch scope (saveStepExecution outside try block) - Fix ConditionStepExecutor catch-all to only catch WorkflowExecutorError - Add pendingUpdate visibility in step summary - Remove findField from types/record.ts and barrel export Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 28 ++++++++++++- .../src/executors/condition-step-executor.ts | 25 +++++++----- .../executors/read-record-step-executor.ts | 24 ++--------- .../executors/update-record-step-executor.ts | 40 ++++++------------- packages/workflow-executor/src/index.ts | 1 - .../workflow-executor/src/types/record.ts | 5 --- .../executors/condition-step-executor.test.ts | 7 +--- 7 files changed, 61 insertions(+), 69 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 82a6abea7..c86da75fb 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 { ExecutionContext, StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, RecordRef } from '../types/record'; +import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { LoadRelatedRecordStepExecutionData, @@ -31,6 +31,30 @@ export default abstract class BaseStepExecutor; + /** Find a field by displayName first, then fallback to fieldName. */ + protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { + return ( + schema.fields.find(f => f.displayName === name) ?? + schema.fields.find(f => f.fieldName === name) + ); + } + + /** Builds a StepExecutionResult with the given status and optional error. */ + protected buildOutcomeResult( + status: 'success' | 'error' | 'awaiting-input', + error?: string, + ): StepExecutionResult { + return { + stepOutcome: { + type: 'ai-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status, + ...(error !== undefined && { error }), + }, + }; + } + /** * Returns a SystemMessage array summarizing previously executed steps. * Empty array when there is no history. Ready to spread into a messages array. @@ -73,6 +97,8 @@ export default abstract class BaseStepExecutor(messages, tool); - } catch (error: unknown) { - return { - stepOutcome: { - type: 'condition', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'error', - error: (error as Error).message, - }, - }; + } 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; } const { option: selectedOption, reasoning } = args; 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 8b25bd363..3f8415cfa 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -9,7 +9,6 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoResolvedFieldsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -import { findField } from '../types/record'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. Select the field(s) that best answer the request. You can read one field or multiple fields at once. @@ -33,7 +32,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor findField(schema, name)?.fieldName) + .map(name => this.findField(schema, name)?.fieldName) .filter((name): name is string => name !== undefined); if (resolvedFieldNames.length === 0) { @@ -48,15 +47,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - const field = findField(schema, name); + const field = this.findField(schema, name); if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: 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 ef5cab565..ec5789173 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -9,7 +9,6 @@ import { z } from 'zod'; import { NoWritableFieldsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -import { findField } from '../types/record'; const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. Select the field to update and provide the new value. @@ -109,23 +108,16 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { + let updated: { values: Record }; + try { const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); const fieldName = this.resolveFieldName(schema, fieldDisplayName); - const updated = await this.context.agentPort.updateRecord( + updated = await this.context.agentPort.updateRecord( selectedRecordRef.collectionName, selectedRecordRef.recordId, { [fieldName]: value }, ); - - await this.context.runStore.saveStepExecution({ - ...existingExecution, - type: 'update-record', - stepIndex: this.context.stepIndex, - executionParams: { fieldDisplayName, value }, - executionResult: { updatedValues: updated.values }, - selectedRecordRef, - }); } catch (error) { if (error instanceof WorkflowExecutorError) { return this.buildOutcomeResult('error', error.message); @@ -134,22 +126,16 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor f.fieldName === name || f.displayName === name); -} - // -- Record types (data — source: AgentPort/RunStore) -- /** Lightweight pointer to a specific record. */ 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 3500dcce6..c43904244 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -261,7 +261,7 @@ describe('ConditionStepExecutor', () => { }); describe('error propagation', () => { - it('returns error status when model invocation fails', async () => { + it('lets infrastructure errors propagate', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); const context = makeContext({ @@ -269,10 +269,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('API timeout'); + await expect(executor.execute()).rejects.toThrow('API timeout'); }); it('lets run store errors propagate', async () => { From bc3d0c65c7f4d147cc0453d7020fe7e699625855 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 16:37:42 +0100 Subject: [PATCH 14/56] refactor(workflow-executor): rename AiTask to RecordTask for clarity Condition steps handle BPMN routing, record-task steps operate on client data. The previous "ai-task" name was misleading since both step types use AI. Rename to "record-task" to reflect the actual distinction. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 2 +- .../src/executors/read-record-step-executor.ts | 4 ++-- .../src/executors/update-record-step-executor.ts | 4 ++-- packages/workflow-executor/src/index.ts | 6 +++--- .../workflow-executor/src/types/step-definition.ts | 4 ++-- .../src/types/step-execution-data.ts | 8 ++++---- packages/workflow-executor/src/types/step-outcome.ts | 12 ++++++------ .../test/executors/base-step-executor.test.ts | 12 ++++++------ .../test/executors/read-record-step-executor.test.ts | 8 ++++---- .../executors/update-record-step-executor.test.ts | 8 ++++---- 10 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index c86da75fb..f04361bee 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -46,7 +46,7 @@ export default abstract class BaseStepExecutor { +export default class ReadRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); 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 ec5789173..17377fcf2 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -1,6 +1,6 @@ import type { StepExecutionResult, UserInput } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; -import type { AiTaskStepDefinition } from '../types/step-definition'; +import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { UpdateRecordStepExecutionData } from '../types/step-execution-data'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -18,7 +18,7 @@ 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.`; -export default class UpdateRecordStepExecutor extends BaseStepExecutor { +export default class UpdateRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { // Branch A -- Re-entry with user confirmation if (this.context.userInput) { diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 1b7aeab83..e85a54549 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -1,14 +1,14 @@ export { StepType } from './types/step-definition'; export type { ConditionStepDefinition, - AiTaskStepDefinition, + RecordTaskStepDefinition, StepDefinition, } from './types/step-definition'; export type { StepStatus, ConditionStepOutcome, - AiTaskStepOutcome, + RecordTaskStepOutcome, StepOutcome, } from './types/step-outcome'; @@ -19,7 +19,7 @@ export type { ConditionStepExecutionData, ReadRecordStepExecutionData, UpdateRecordStepExecutionData, - AiTaskStepExecutionData, + RecordTaskStepExecutionData, LoadRelatedRecordStepExecutionData, ExecutedStepExecutionData, StepExecutionData, diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index ca23e5b41..fb3a5f0b2 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -19,7 +19,7 @@ export interface ConditionStepDefinition extends BaseStepDefinition { options: [string, ...string[]]; } -export interface AiTaskStepDefinition extends BaseStepDefinition { +export interface RecordTaskStepDefinition extends BaseStepDefinition { type: Exclude; recordSourceStepId?: string; automaticCompletion?: boolean; @@ -27,4 +27,4 @@ export interface AiTaskStepDefinition extends BaseStepDefinition { remoteToolsSourceId?: string; } -export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; +export type StepDefinition = ConditionStepDefinition | RecordTaskStepDefinition; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 0d1459461..f3be7d5e4 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -57,8 +57,8 @@ export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { // -- Generic AI Task (fallback for untyped steps) -- -export interface AiTaskStepExecutionData extends BaseStepExecutionData { - type: 'ai-task'; +export interface RecordTaskStepExecutionData extends BaseStepExecutionData { + type: 'record-task'; executionParams?: Record; executionResult?: Record; toolConfirmationInterruption?: Record; @@ -77,14 +77,14 @@ export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData - | AiTaskStepExecutionData + | RecordTaskStepExecutionData | LoadRelatedRecordStepExecutionData; export type ExecutedStepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData | UpdateRecordStepExecutionData - | AiTaskStepExecutionData; + | RecordTaskStepExecutionData; // TODO: this condition should change when load-related-record gets its own executor // and produces executionParams/executionResult like other steps. diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index 9a564748e..37f53afa0 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -6,10 +6,10 @@ type BaseStepStatus = 'success' | 'error'; export type ConditionStepStatus = BaseStepStatus | 'manual-decision'; /** AI task steps can pause mid-execution to await user input (e.g. tool confirmation). */ -export type AiTaskStepStatus = BaseStepStatus | 'awaiting-input'; +export type RecordTaskStepStatus = BaseStepStatus | 'awaiting-input'; /** Union of all step statuses. */ -export type StepStatus = ConditionStepStatus | AiTaskStepStatus; +export type StepStatus = ConditionStepStatus | RecordTaskStepStatus; /** * StepOutcome is sent to the orchestrator — it must NEVER contain client data. @@ -30,9 +30,9 @@ export interface ConditionStepOutcome extends BaseStepOutcome { selectedOption?: string; } -export interface AiTaskStepOutcome extends BaseStepOutcome { - type: 'ai-task'; - status: AiTaskStepStatus; +export interface RecordTaskStepOutcome extends BaseStepOutcome { + type: 'record-task'; + status: RecordTaskStepStatus; } -export type StepOutcome = ConditionStepOutcome | AiTaskStepOutcome; +export type StepOutcome = ConditionStepOutcome | RecordTaskStepOutcome; 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 9fff9ab99..22dbb5668 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -220,14 +220,14 @@ describe('BaseStepExecutor', () => { expect(result).toContain('"error":"AI could not match an option"'); }); - it('includes status in History for ai-task steps without RunStore data', async () => { + 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: 'ai-task', + type: 'record-task', stepId: 'ai-step', stepIndex: 0, status: 'awaiting-input', @@ -263,7 +263,7 @@ describe('BaseStepExecutor', () => { prompt: 'Read name', }, stepOutcome: { - type: 'ai-task', + type: 'record-task', stepId: 'read-customer', stepIndex: 1, status: 'success', @@ -275,7 +275,7 @@ describe('BaseStepExecutor', () => { previousSteps: [condEntry, aiEntry], runStore: makeMockRunStore([ { - type: 'ai-task', + type: 'record-task', stepIndex: 1, executionParams: { answer: 'John Doe' }, }, @@ -327,7 +327,7 @@ describe('BaseStepExecutor', () => { prompt: 'Do something', }, stepOutcome: { - type: 'ai-task', + type: 'record-task', stepId: 'ai-step', stepIndex: 0, status: 'success', @@ -339,7 +339,7 @@ describe('BaseStepExecutor', () => { previousSteps: [entry], runStore: makeMockRunStore([ { - type: 'ai-task', + type: 'record-task', stepIndex: 0, }, ]), 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 0f0863dc1..35ae85925 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 @@ -3,13 +3,13 @@ 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 { AiTaskStepDefinition } from '../../src/types/step-definition'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import { NoRecordsError, RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; import { StepType } from '../../src/types/step-definition'; -function makeStep(overrides: Partial = {}): AiTaskStepDefinition { +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { return { type: StepType.ReadRecord, prompt: 'Read the customer email', @@ -99,8 +99,8 @@ function makeMockModel( } function makeContext( - overrides: Partial> = {}, -): ExecutionContext { + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', stepId: 'read-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 0786f651f..8815a2cb8 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 @@ -3,14 +3,14 @@ import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; import type { ExecutionContext, UserInput } from '../../src/types/execution'; import type { CollectionSchema, RecordRef } from '../../src/types/record'; -import type { AiTaskStepDefinition } from '../../src/types/step-definition'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import type { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; import { WorkflowExecutorError } from '../../src/errors'; import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; import { StepType } from '../../src/types/step-definition'; -function makeStep(overrides: Partial = {}): AiTaskStepDefinition { +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { return { type: StepType.UpdateRecord, prompt: 'Set the customer status to active', @@ -96,8 +96,8 @@ function makeMockModel(toolCallArgs?: Record, toolName = 'updat } function makeContext( - overrides: Partial> = {}, -): ExecutionContext { + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', stepId: 'update-1', From d895154372e444515e02eadee3b8eec2c3bbf19b Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 17:06:00 +0100 Subject: [PATCH 15/56] refactor(workflow-executor): remove unused fields from RecordTaskStepDefinition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop recordSourceStepId and remoteToolsSourceId — both were declared but never read. allowedTools is kept as the orchestrator-provided tool filter. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/types/step-definition.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index fb3a5f0b2..44c93a2af 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -21,10 +21,8 @@ export interface ConditionStepDefinition extends BaseStepDefinition { export interface RecordTaskStepDefinition extends BaseStepDefinition { type: Exclude; - recordSourceStepId?: string; automaticCompletion?: boolean; allowedTools?: string[]; - remoteToolsSourceId?: string; } export type StepDefinition = ConditionStepDefinition | RecordTaskStepDefinition; From d1651dbed8e28f79e97ecf8fe02a15b5e948dc88 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 17:11:32 +0100 Subject: [PATCH 16/56] feat(workflow-executor): add ToolTaskStepDefinition for MCP tool steps Introduces StepType.ToolTask ('tool-task') and ToolTaskStepDefinition for steps where the AI freely selects and executes a tool from allowedTools. Moves allowedTools out of RecordTaskStepDefinition where it didn't belong. Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/index.ts | 1 + .../workflow-executor/src/types/step-definition.ts | 10 ++++++++-- packages/workflow-executor/test/index.test.ts | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index e85a54549..d41c2663b 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, + ToolTaskStepDefinition, StepDefinition, } from './types/step-definition'; diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index 44c93a2af..4a82626d5 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -6,6 +6,7 @@ export enum StepType { UpdateRecord = 'update-record', TriggerAction = 'trigger-action', LoadRelatedRecord = 'load-related-record', + ToolTask = 'tool-task', } interface BaseStepDefinition { @@ -20,9 +21,14 @@ export interface ConditionStepDefinition extends BaseStepDefinition { } export interface RecordTaskStepDefinition extends BaseStepDefinition { - type: Exclude; + type: Exclude; automaticCompletion?: boolean; +} + +export interface ToolTaskStepDefinition extends BaseStepDefinition { + type: StepType.ToolTask; allowedTools?: string[]; + automaticCompletion?: boolean; } -export type StepDefinition = ConditionStepDefinition | RecordTaskStepDefinition; +export type StepDefinition = ConditionStepDefinition | RecordTaskStepDefinition | ToolTaskStepDefinition; diff --git a/packages/workflow-executor/test/index.test.ts b/packages/workflow-executor/test/index.test.ts index 05affa035..ff302f7a9 100644 --- a/packages/workflow-executor/test/index.test.ts +++ b/packages/workflow-executor/test/index.test.ts @@ -1,9 +1,9 @@ import { StepType } from '../src/index'; describe('StepType', () => { - it('should expose exactly 5 step types', () => { + it('should expose exactly 6 step types', () => { const values = Object.values(StepType); - expect(values).toHaveLength(5); + expect(values).toHaveLength(6); }); it.each([ @@ -12,6 +12,7 @@ describe('StepType', () => { ['UpdateRecord', 'update-record'], ['TriggerAction', 'trigger-action'], ['LoadRelatedRecord', 'load-related-record'], + ['ToolTask', 'tool-task'], ] as const)('should have %s = "%s"', (key, value) => { expect(StepType[key]).toBe(value); }); From 6a49657f3a80534c72467072a89ee0ac2143105c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 19:08:04 +0100 Subject: [PATCH 17/56] fix(workflow-executor): fix prettier formatting on StepDefinition union type Co-Authored-By: Claude Sonnet 4.6 --- packages/workflow-executor/src/types/step-definition.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index 4a82626d5..a5c23f425 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -31,4 +31,7 @@ export interface ToolTaskStepDefinition extends BaseStepDefinition { automaticCompletion?: boolean; } -export type StepDefinition = ConditionStepDefinition | RecordTaskStepDefinition | ToolTaskStepDefinition; +export type StepDefinition = + | ConditionStepDefinition + | RecordTaskStepDefinition + | ToolTaskStepDefinition; From 949d77ad2df11f47310241877473d9dea4a3a75e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 09:51:32 +0100 Subject: [PATCH 18/56] refactor(workflow-executor): introduce UpdateTarget to reduce resolveAndUpdate arity - Extract UpdateTarget interface {selectedRecordRef, fieldDisplayName, value} so resolveAndUpdate takes 2 params instead of 4 (addresses qlty reviewer comment) - Collapse 3 uninitialized let declarations in handleFirstCall into one target object - Fix missing runId in getAvailableRecordRefs and update-record saveStepExecution calls - Strengthen test assertions to include runId as first arg Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/base-step-executor.ts | 2 +- .../executors/update-record-step-executor.ts | 50 ++++++++++--------- .../update-record-step-executor.test.ts | 5 ++ 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index f04361bee..e83950c6d 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -148,7 +148,7 @@ export default abstract class BaseStepExecutor { - const stepExecutions = await this.context.runStore.getStepExecutions(); + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); const relatedRecords = stepExecutions .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') .map(e => e.record); 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 17377fcf2..56e603f10 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -18,6 +18,12 @@ 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 UpdateTarget { + selectedRecordRef: RecordRef; + fieldDisplayName: string; + value: string; +} + export default class UpdateRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { // Branch A -- Re-entry with user confirmation @@ -30,7 +36,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { - const stepExecutions = await this.context.runStore.getStepExecutions(); + 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, @@ -41,7 +47,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); - let selectedRecordRef: RecordRef; - let fieldDisplayName: string; - let value: string; + let target: UpdateTarget; try { - selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); const args = await this.selectFieldAndValue(schema, step.prompt); - fieldDisplayName = args.fieldName; - value = args.value; + target = { selectedRecordRef, fieldDisplayName: args.fieldName, value: args.value }; } catch (error) { if (error instanceof WorkflowExecutorError) { return this.buildOutcomeResult('error', error.message); @@ -83,15 +86,15 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { + const { selectedRecordRef, fieldDisplayName, value } = target; let updated: { values: Record }; try { @@ -126,7 +128,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', expect.objectContaining({ type: 'update-record', stepIndex: 0, @@ -174,6 +175,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('awaiting-input'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', expect.objectContaining({ type: 'update-record', stepIndex: 0, @@ -209,6 +211,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', expect.objectContaining({ type: 'update-record', executionParams: { fieldDisplayName: 'Status', value: 'active' }, @@ -240,6 +243,7 @@ describe('UpdateRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(agentPort.updateRecord).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', expect.objectContaining({ executionResult: { skipped: true }, pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, @@ -329,6 +333,7 @@ describe('UpdateRecordStepExecutor', () => { expect(updateTool.name).toBe('update-record-field'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', expect.objectContaining({ pendingUpdate: { fieldDisplayName: 'Order Status', value: 'shipped' }, selectedRecordRef: expect.objectContaining({ From b939ac73a782fe36b021dd83be2f17729d2866c4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 09:56:00 +0100 Subject: [PATCH 19/56] test(workflow-executor): address PR review gaps on UpdateRecordStepExecutor Code changes: - Add JSDoc on buildOutcomeResult warning it is for record-task executors only - Add runtime guard on userInput.type in handleConfirmation (WorkflowExecutorError if an unexpected type is received, guarding against future UserInput union extension) New tests: - buildStepSummary: pendingUpdate branch coverage (update-record step with pendingUpdate but no executionParams emits Pending: in AI context) - handleConfirmation: stepIndex mismatch and missing pendingUpdate cases - stepOutcome shape: type/stepId/stepIndex asserted on happy path - unexpected userInput type: runtime guard verified - findField fieldName fallback: update succeeds when AI returns raw fieldName - schema caching: getCollectionSchema called once per collection in Branch B - RunStore error propagation: all four saveStepExecution/getStepExecutions call sites Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/base-step-executor.ts | 6 +- .../executors/update-record-step-executor.ts | 6 + .../test/executors/base-step-executor.test.ts | 35 ++++ .../update-record-step-executor.test.ts | 153 ++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index e83950c6d..bf677691a 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -39,7 +39,11 @@ export default abstract class BaseStepExecutor { + if (userInput.type !== 'confirmation') { + throw new WorkflowExecutorError( + `UpdateRecordStepExecutor received unexpected userInput type: "${(userInput as { type: string }).type}"`, + ); + } + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); const execution = stepExecutions.find( (e): e is UpdateRecordStepExecutionData => 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 22dbb5668..9e7dc5ebe 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -355,6 +355,41 @@ describe('BaseStepExecutor', () => { 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, + pendingUpdate: { fieldDisplayName: '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('"fieldDisplayName":"Status"'); + expect(result).toContain('"value":"active"'); + 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/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index 1225badd4..3db784b8b 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 @@ -263,6 +263,41 @@ describe('UpdateRecordStepExecutor', () => { await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); }); + + it('throws when execution exists but stepIndex does not match', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'update-record', + stepIndex: 5, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); + }); + + it('throws when execution exists but pendingUpdate is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'update-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); + }); }); describe('multi-record AI selection', () => { @@ -585,6 +620,124 @@ describe('UpdateRecordStepExecutor', () => { }); }); + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticCompletion: true }) }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('unexpected userInput type', () => { + it('throws when userInput has an unknown type', async () => { + const userInput = { type: 'text-input', value: 'hello' } as unknown as UserInput; + const context = makeContext({ userInput }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow( + 'UpdateRecordStepExecutor received unexpected userInput type: "text-input"', + ); + }); + }); + + describe('findField fieldName fallback', () => { + it('resolves update when AI returns raw fieldName instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns 'status' (fieldName) instead of 'Status' (displayName) + const mockModel = makeMockModel({ fieldName: 'status', value: 'active', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith('customers', [42], { status: 'active' }); + }); + }); + + 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({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + // Branch B calls getCollectionSchema in handleFirstCall and again in resolveAndUpdate + // but the cache should prevent the second network call + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('RunStore error propagation', () => { + it('lets getStepExecutions errors propagate (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: true }; + const context = makeContext({ runStore, userInput }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('DB timeout'); + }); + + it('lets saveStepExecution errors propagate when user rejects (Branch A)', async () => { + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingUpdate: { fieldDisplayName: 'Status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userInput: UserInput = { type: 'confirmation', confirmed: false }; + const context = makeContext({ runStore, userInput }); + const executor = new UpdateRecordStepExecutor(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 UpdateRecordStepExecutor(context); + + await expect(executor.execute()).rejects.toThrow('Disk full'); + }); + + it('lets saveStepExecution errors propagate after successful updateRecord (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runStore, + stepDefinition: makeStep({ automaticCompletion: true }), + }); + const executor = new UpdateRecordStepExecutor(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({ From 180f3fd1d797f52d386bb442a389d67b210ace59 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 10:32:58 +0100 Subject: [PATCH 20/56] =?UTF-8?q?refactor(workflow-executor):=20rename=20T?= =?UTF-8?q?oolTask=E2=86=92McpTask=20and=20automaticCompletion=E2=86=92aut?= =?UTF-8?q?omaticExecution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StepType.ToolTask → StepType.McpTask (wire value: 'tool-task' → 'mcp-task') - ToolTaskStepDefinition → McpTaskStepDefinition - mcpServerId: string[] → string (single ID) - automaticCompletion → automaticExecution on both RecordTaskStepDefinition and McpTaskStepDefinition - Remove TODO rename comments - Update all tests accordingly Co-Authored-By: Claude Sonnet 4.6 --- .../executors/update-record-step-executor.ts | 8 ++++--- packages/workflow-executor/src/index.ts | 1 - .../src/types/step-definition.ts | 16 +++++++------- .../update-record-step-executor.test.ts | 22 +++++++++---------- packages/workflow-executor/test/index.test.ts | 2 +- 5 files changed, 25 insertions(+), 24 deletions(-) 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 25748198d..bd48bc322 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -38,7 +38,9 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor { if (userInput.type !== 'confirmation') { throw new WorkflowExecutorError( - `UpdateRecordStepExecutor received unexpected userInput type: "${(userInput as { type: string }).type}"`, + `UpdateRecordStepExecutor received unexpected userInput type: "${ + (userInput as { type: string }).type + }"`, ); } @@ -90,8 +92,8 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor; - automaticCompletion?: boolean; + type: Exclude; + automaticExecution?: boolean; } -export interface ToolTaskStepDefinition extends BaseStepDefinition { - type: StepType.ToolTask; - allowedTools?: string[]; - automaticCompletion?: boolean; +export interface McpTaskStepDefinition extends BaseStepDefinition { + type: StepType.McpTask; + mcpServerId?: string; + automaticExecution?: boolean; } export type StepDefinition = | ConditionStepDefinition | RecordTaskStepDefinition - | ToolTaskStepDefinition; + | McpTaskStepDefinition; 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 3db784b8b..1df654e35 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 @@ -119,7 +119,7 @@ function makeContext( } describe('UpdateRecordStepExecutor', () => { - describe('automaticCompletion: update direct (Branch B)', () => { + describe('automaticExecution: update direct (Branch B)', () => { it('updates the record and returns success', async () => { const updatedValues = { status: 'active', name: 'John Doe' }; const agentPort = makeMockAgentPort(updatedValues); @@ -133,7 +133,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -157,7 +157,7 @@ describe('UpdateRecordStepExecutor', () => { }); }); - describe('without automaticCompletion: awaiting-input (Branch C)', () => { + describe('without automaticExecution: awaiting-input (Branch C)', () => { it('saves execution and returns awaiting-input', async () => { const mockModel = makeMockModel({ fieldName: 'Status', @@ -432,7 +432,7 @@ describe('UpdateRecordStepExecutor', () => { ); }); - it('returns error when field is not found during automaticCompletion (Branch B)', async () => { + 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({ fieldName: 'NonExistentField', @@ -441,7 +441,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -544,7 +544,7 @@ describe('UpdateRecordStepExecutor', () => { model: mockModel.model, agentPort, runStore, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -593,7 +593,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -622,7 +622,7 @@ describe('UpdateRecordStepExecutor', () => { describe('stepOutcome shape', () => { it('emits correct type, stepId and stepIndex in the outcome', async () => { - const context = makeContext({ stepDefinition: makeStep({ automaticCompletion: true }) }); + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -656,7 +656,7 @@ describe('UpdateRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -672,7 +672,7 @@ describe('UpdateRecordStepExecutor', () => { const workflowPort = makeMockWorkflowPort(); const context = makeContext({ workflowPort, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); @@ -730,7 +730,7 @@ describe('UpdateRecordStepExecutor', () => { }); const context = makeContext({ runStore, - stepDefinition: makeStep({ automaticCompletion: true }), + stepDefinition: makeStep({ automaticExecution: true }), }); const executor = new UpdateRecordStepExecutor(context); diff --git a/packages/workflow-executor/test/index.test.ts b/packages/workflow-executor/test/index.test.ts index ff302f7a9..1267b1cbb 100644 --- a/packages/workflow-executor/test/index.test.ts +++ b/packages/workflow-executor/test/index.test.ts @@ -12,7 +12,7 @@ describe('StepType', () => { ['UpdateRecord', 'update-record'], ['TriggerAction', 'trigger-action'], ['LoadRelatedRecord', 'load-related-record'], - ['ToolTask', 'tool-task'], + ['McpTask', 'mcp-task'], ] as const)('should have %s = "%s"', (key, value) => { expect(StepType[key]).toBe(value); }); From 1309b54086485a8f27e1309a2b540bab4ba59626 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 10:35:54 +0100 Subject: [PATCH 21/56] test(workflow-executor): rename misleading stepId 'ai-step' to 'read-record-1' Co-Authored-By: Claude Sonnet 4.6 --- .../test/executors/base-step-executor.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 9e7dc5ebe..45f5bc9c4 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -228,7 +228,7 @@ describe('BaseStepExecutor', () => { }, stepOutcome: { type: 'record-task', - stepId: 'ai-step', + stepId: 'read-record-1', stepIndex: 0, status: 'awaiting-input', }, @@ -245,7 +245,7 @@ describe('BaseStepExecutor', () => { .buildPreviousStepsMessages() .then(msgs => msgs[0]?.content ?? ''); - expect(result).toContain('Step "ai-step"'); + expect(result).toContain('Step "read-record-1"'); expect(result).toContain('History: {"status":"awaiting-input"}'); }); @@ -328,7 +328,7 @@ describe('BaseStepExecutor', () => { }, stepOutcome: { type: 'record-task', - stepId: 'ai-step', + stepId: 'read-record-1', stepIndex: 0, status: 'success', }, @@ -350,7 +350,7 @@ describe('BaseStepExecutor', () => { .buildPreviousStepsMessages() .then(msgs => msgs[0]?.content ?? ''); - expect(result).toContain('Step "ai-step"'); + expect(result).toContain('Step "read-record-1"'); expect(result).toContain('Prompt: Do something'); expect(result).not.toContain('Input:'); }); From d99fe27f914565683bcebd8f3c478e4e7512dd5e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 11:44:36 +0100 Subject: [PATCH 22/56] refactor(workflow-executor): simplify userInput to userConfirmed boolean Replaces the UserInput discriminated union with a plain boolean field userConfirmed on ExecutionContext and PendingStepExecution. Since the only user input type in practice is a boolean confirmation, the union wrapper adds complexity with no benefit. Co-Authored-By: Claude Sonnet 4.6 --- .../executors/update-record-step-executor.ts | 18 ++----- packages/workflow-executor/src/index.ts | 1 - .../workflow-executor/src/types/execution.ts | 6 +-- .../update-record-step-executor.test.ts | 54 ++++++++----------- 4 files changed, 28 insertions(+), 51 deletions(-) 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 bd48bc322..5d210b859 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -1,4 +1,4 @@ -import type { StepExecutionResult, UserInput } from '../types/execution'; +import type { StepExecutionResult } from '../types/execution'; import type { CollectionSchema, RecordRef } from '../types/record'; import type { RecordTaskStepDefinition } from '../types/step-definition'; import type { UpdateRecordStepExecutionData } from '../types/step-execution-data'; @@ -27,23 +27,15 @@ interface UpdateTarget { export default class UpdateRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { // Branch A -- Re-entry with user confirmation - if (this.context.userInput) { - return this.handleConfirmation(this.context.userInput); + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); } // Branches B & C -- First call return this.handleFirstCall(); } - private async handleConfirmation(userInput: UserInput): Promise { - if (userInput.type !== 'confirmation') { - throw new WorkflowExecutorError( - `UpdateRecordStepExecutor received unexpected userInput type: "${ - (userInput as { type: string }).type - }"`, - ); - } - + private async handleConfirmation(): Promise { const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); const execution = stepExecutions.find( (e): e is UpdateRecordStepExecutionData => @@ -54,7 +46,7 @@ export default class UpdateRecordStepExecutor extends BaseStepExecutor; - readonly userInput?: UserInput; + readonly userConfirmed?: boolean; } export interface StepExecutionResult { @@ -41,5 +39,5 @@ export interface ExecutionContext readonly runStore: RunStore; readonly previousSteps: ReadonlyArray>; readonly remoteTools: readonly unknown[]; - readonly userInput?: UserInput; + readonly userConfirmed?: boolean; } 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 1df654e35..1d2ace371 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 @@ -1,7 +1,7 @@ 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, UserInput } from '../../src/types/execution'; +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 { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; @@ -202,8 +202,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ agentPort, runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -234,8 +234,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: false }; - const context = makeContext({ agentPort, runStore, userInput }); + const userConfirmed = false; + const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -257,8 +257,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); @@ -275,8 +275,8 @@ describe('UpdateRecordStepExecutor', () => { }, ]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); @@ -292,8 +292,8 @@ describe('UpdateRecordStepExecutor', () => { }, ]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('No pending update found for this step'); @@ -420,8 +420,8 @@ describe('UpdateRecordStepExecutor', () => { getStepExecutions: jest.fn().mockResolvedValue([execution]), }); const workflowPort = makeMockWorkflowPort({ customers: schema }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ runStore, workflowPort, userInput }); + const userConfirmed = true; + const context = makeContext({ runStore, workflowPort, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -570,8 +570,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ agentPort, runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); const result = await executor.execute(); @@ -612,8 +612,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([execution]), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ agentPort, runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Connection refused'); @@ -636,18 +636,6 @@ describe('UpdateRecordStepExecutor', () => { }); }); - describe('unexpected userInput type', () => { - it('throws when userInput has an unknown type', async () => { - const userInput = { type: 'text-input', value: 'hello' } as unknown as UserInput; - const context = makeContext({ userInput }); - const executor = new UpdateRecordStepExecutor(context); - - await expect(executor.execute()).rejects.toThrow( - 'UpdateRecordStepExecutor received unexpected userInput type: "text-input"', - ); - }); - }); - describe('findField fieldName fallback', () => { it('resolves update when AI returns raw fieldName instead of displayName', async () => { const agentPort = makeMockAgentPort(); @@ -689,8 +677,8 @@ describe('UpdateRecordStepExecutor', () => { const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), }); - const userInput: UserInput = { type: 'confirmation', confirmed: true }; - const context = makeContext({ runStore, userInput }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('DB timeout'); @@ -707,8 +695,8 @@ describe('UpdateRecordStepExecutor', () => { getStepExecutions: jest.fn().mockResolvedValue([execution]), saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), }); - const userInput: UserInput = { type: 'confirmation', confirmed: false }; - const context = makeContext({ runStore, userInput }); + const userConfirmed = false; + const context = makeContext({ runStore, userConfirmed }); const executor = new UpdateRecordStepExecutor(context); await expect(executor.execute()).rejects.toThrow('Disk full'); From c884d37b223bafb0fa0d9830c7b2974550699f96 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 20 Mar 2026 11:37:36 +0100 Subject: [PATCH 23/56] 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 8b872a893..8651daa91 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 bf677691a..6cbc1d33a 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 2a1b3df0a..c382496c5 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 f3be7d5e4..0bdb16a31 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 000000000..9c369f608 --- /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 24/56] 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 c315d4df8..fa1564574 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 0bdb16a31..0bacd2c99 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 9c369f608..3d9a52cea 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 25/56] 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 f0318c97b..2a006024b 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 23eb651b9..3b9a60743 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 fa1564574..33c08ead4 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 45f5bc9c4..738107ac8 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 35ae85925..21d7559f3 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 3d9a52cea..d3294953a 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 1d2ace371..578aa715f 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 26/56] =?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 3b9a60743..7a35c569b 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 3eed9ea69..41772726a 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 21d7559f3..8effaf210 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 27/56] 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 7a35c569b..37fc29f99 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 41772726a..b7245d1b0 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 8effaf210..8baf61d6e 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 28/56] 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 37fc29f99..17a85cf62 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 29/56] 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 17a85cf62..896ff73ff 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 d3294953a..b151e4eb0 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 30/56] 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 6cbc1d33a..e7f72cff3 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 cd957c9ee..0fcd9f562 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 738107ac8..76ba1c64c 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 b151e4eb0..d5bd9eb87 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 578aa715f..8b4ebda1c 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 31/56] 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 8651daa91..83a36eda3 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 e7f72cff3..f7a5ffa36 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 09128a73b..bd6288f38 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 26e91eb57..5aaa6bd5c 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 86b884109..15db70627 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 0fcd9f562..bdb3a987c 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 a5555c74a..edc2e3cbc 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 b4205fb67..b32f9358e 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 37f53afa0..b5df5ac9e 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 76ba1c64c..ee1af9864 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 c43904244..7a4c5fa6b 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 d5bd9eb87..0cb3620a7 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 8b4ebda1c..286fea68f 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 32/56] 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 2a006024b..1bb8c3326 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 cf8949a1a..5dbfd1487 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 83a36eda3..422c2413d 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 f7a5ffa36..2a57c7889 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 5aaa6bd5c..36ab70756 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 edc2e3cbc..fe0e43e8c 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 b5070c39f..2237600fb 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 b32f9358e..3fa13c893 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 b564eeaf5..38b8f4dd6 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 ee1af9864..32b91cfff 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 000000000..141eabd0b --- /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 e6854c715..abd7f27cf 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 0cb3620a7..9b72e4a62 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 286fea68f..85d8db0cf 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 33/56] 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 1bb8c3326..dbea279bd 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 422c2413d..efe3cd85e 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 2a57c7889..b6bba6539 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 7a4c5fa6b..3f1bbe540 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 141eabd0b..1935cf35b 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 abd7f27cf..1d4145f4a 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 9b72e4a62..0efef7002 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 85d8db0cf..2f9da2768 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 34/56] 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 000000000..41ed7e3fa --- /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 72ea42936..cc96d395b 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 0347ae087..8a3bc6095 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 000000000..017f8742a --- /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 25f71fe44..efd759ea5 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 f4d415b4c..4e57d24e4 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 35/56] 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 41ed7e3fa..cbe989ab3 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 36/56] =?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 5dbfd1487..06017640f 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 b6bba6539..95f5f69e0 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 0055c873c..284a3b3c0 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 36ab70756..9d3834b2c 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 3fa13c893..1143cea6c 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 38b8f4dd6..cca7c3b4f 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 cb6b64cbf..abc78c235 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 1935cf35b..d1d01fbf4 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 1d4145f4a..7a9c6ec9d 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 000000000..b6765997c --- /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 000000000..6af99f0cf --- /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 0efef7002..85ae764b3 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 2f9da2768..2b2bf7fce 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 37/56] 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 06017640f..fb146b347 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 95f5f69e0..6b191038c 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 8c87199e2..8a8603552 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 39709f68c..abe155924 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 0dd548321..cd2f81925 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 8a3bc6095..565063bd9 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 39e630921..4a95c92cd 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 1143cea6c..f76fa6bea 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 b6765997c..82f3f76fc 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 6af99f0cf..27b330836 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 85ae764b3..61a313a21 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 38/56] 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 fb146b347..963e7de4b 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 39/56] 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 000000000..3a4ae4692 --- /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 efd759ea5..59c4ed842 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 abc78c235..9dee1bc68 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 3f1bbe540..696b200ab 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 d1d01fbf4..330a72a88 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 000000000..ddabbb9dd --- /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 7a9c6ec9d..310bf2328 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 61a313a21..debd9783c 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 2b2bf7fce..51f3b04a4 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 40/56] 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 efe3cd85e..37b5d08cc 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 6b191038c..a79d7cf3d 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 3a4ae4692..3aafc5c7a 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 ddabbb9dd..d1502e65a 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 41/56] 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 37b5d08cc..638d7c4e4 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 a79d7cf3d..54efae285 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 284a3b3c0..c5c645aa2 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 9d3834b2c..49b34fb65 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 cd2f81925..928f08a97 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 c54c34e9d..6c2383fb1 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 565063bd9..d323d450a 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 330a72a88..bcd5f1dc0 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 310bf2328..bc4c0696a 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 000000000..33ab53d07 --- /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 debd9783c..f67fd3af2 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 51f3b04a4..dc8fffd56 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 42/56] 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 cf94a502e..bda0e14ce 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 7b7322f05..f0067cae7 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 f76fa6bea..d71190b45 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 c7c7add7e..371ee67d9 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 43/56] 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 c355e0f9b..6fa9cb86a 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 6c6929dd2..f926eecbf 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 c0817275a..8cfab73fe 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 cc7ca89ae..1adfd64fe 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 7cefcf3dc..0138fa0fd 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 44/56] 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 d622773e8..695cb0997 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 54efae285..7c50bd777 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 bd6288f38..43fd995e3 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 c5c645aa2..904037723 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 3aafc5c7a..4180f7117 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 49b34fb65..4b8622014 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 928f08a97..167e3f018 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 6c2383fb1..97c0cb1d4 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 59c4ed842..3df067180 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 5eb3a3cc8..fcf1ab1c4 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 45/56] 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 bda0e14ce..4a6a93b0a 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 46/56] 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 7c50bd777..b09a7a8e3 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 652772c71..49db62f0f 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 fcf1ab1c4..8c3f5c254 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 0ea16bd27..d6f1bcef0 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 47/56] 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 4180f7117..8a5560e1b 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 8a8603552..55fdda59a 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 d71190b45..45fa00b76 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 b5df5ac9e..38b420610 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 d1502e65a..128e455fd 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 82f3f76fc..52bcbce4b 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 48/56] 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 b09a7a8e3..c2c5c3fbc 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 8a5560e1b..fe9c84e9c 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 f0067cae7..86b884109 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 d323d450a..3075c5f41 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 49db62f0f..5ebb8c486 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 38b420610..b1fbe2d46 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 128e455fd..e487906aa 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 d6f1bcef0..4a474a99f 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 49/56] 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 5ebb8c486..26f0db368 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 50/56] 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 000000000..1095d9db5 --- /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 26f0db368..7dff911c8 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 4a474a99f..ec6e6ebf7 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 51/56] 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 638d7c4e4..bd2548b69 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 c2c5c3fbc..4472ff6ba 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 000000000..a28251b6d --- /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 1095d9db5..518555db1 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 7dff911c8..becf15631 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 3df067180..3ec08b334 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 b1fbe2d46..3421b6017 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 000000000..a237d3f13 --- /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 ec6e6ebf7..2b1639ded 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 52/56] 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 a28251b6d..000000000 --- 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 518555db1..2f321dd63 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 a237d3f13..000000000 --- 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 2b1639ded..30384cd4f 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 000000000..e044e79f8 --- /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 53/56] 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 16037570b..47f45a6c1 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 bd2548b69..4560bafde 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 cc96d395b..10062dedd 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 110ac3fb4..223123b75 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 becf15631..b97a8260a 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 9e69a04ea..8b38812df 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 bcd5f1dc0..573cd00ec 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 e487906aa..5525014dd 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 bc4c0696a..cee40bc8c 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 f67fd3af2..7bb0a77df 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 dc8fffd56..00ed053d0 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 4e57d24e4..e660b926a 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 30384cd4f..52ba1c0c3 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 54/56] 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 dbea279bd..a54d59988 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 55/56] =?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 000000000..5fc24d0da --- /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 904037723..998157bb6 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 45fa00b76..edd5f8df0 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 573cd00ec..46910f72d 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 52bcbce4b..dc4f65db2 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 27b330836..a6a5c743f 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 56/56] 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 5fc24d0da..1313a0b13 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)