From 201c2315873940e25dccfbc293a94abe10ffb907 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 12:21:20 +0100 Subject: [PATCH 01/37] feat(workflow-executor): add ReadRecordStepExecutor Implement the read-record step executor that allows AI to read fields from a record. Includes AI-based record selection when multiple records are available, and field selection via the read-selected-record-field tool. fixes PRD-218 Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/CLAUDE.md | 5 +- packages/workflow-executor/src/errors.ts | 12 + .../executors/read-record-step-executor.ts | 187 ++++++ packages/workflow-executor/src/index.ts | 3 + .../src/types/step-definition.ts | 9 +- .../read-record-step-executor.test.ts | 571 ++++++++++++++++++ 6 files changed, 778 insertions(+), 9 deletions(-) create mode 100644 packages/workflow-executor/src/executors/read-record-step-executor.ts create mode 100644 packages/workflow-executor/test/executors/read-record-step-executor.test.ts diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 2be0522bc..c9f799dd9 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 +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError ├── types/ # Core type definitions (@draft) │ ├── step-definition.ts # StepType enum + step definition interfaces │ ├── step-history.ts # Step outcome tracking types @@ -55,7 +55,8 @@ src/ │ └── run-store.ts # Interface for persisting run state (scoped to a run) ├── 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) +│ ├── condition-step-executor.ts # AI-powered condition step (chooses among options) +│ └── read-record-step-executor.ts # AI-powered record field reading step └── index.ts # Barrel exports ``` diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index d735977d4..883b36e4b 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -27,3 +27,15 @@ export class RecordNotFoundError extends WorkflowExecutorError { super(`Record not found: collection "${collectionName}", id "${recordId}"`); } } + +export class NoRecordsError extends WorkflowExecutorError { + constructor() { + super('No records available'); + } +} + +export class NoReadableFieldsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super(`No readable fields on record from collection "${collectionName}"`); + } +} diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts new file mode 100644 index 000000000..6157fb10f --- /dev/null +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -0,0 +1,187 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { RecordData } from '../types/record'; +import type { AiTaskStepDefinition } from '../types/step-definition'; +import type { AiTaskStepHistory } from '../types/step-history'; + +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import { NoReadableFieldsError, NoRecordsError, WorkflowExecutorError } from '../errors'; +import BaseStepExecutor from './base-step-executor'; + +interface FieldReadSuccess { + value: unknown; + fieldName: string; + displayName: string; +} + +interface FieldReadError { + error: string; + fieldName: string; +} + +type FieldReadResult = (FieldReadSuccess | FieldReadError)[]; + +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. + +Important rules: +- Be precise: only read fields that are 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 ReadRecordStepExecutor extends BaseStepExecutor< + AiTaskStepDefinition, + AiTaskStepHistory +> { + async execute( + step: AiTaskStepDefinition, + stepHistory: AiTaskStepHistory, + ): Promise { + const records = await this.context.runStore.getRecords(); + + let selectedRecord: RecordData; + let fieldNames: string | string[]; + + try { + selectedRecord = await this.selectRecord(records, step.prompt); + fieldNames = await this.selectFields(selectedRecord, step.prompt); + } catch (error) { + return { stepHistory: { ...stepHistory, status: 'error', error: (error as Error).message } }; + } + + const fieldResults = this.readFieldValues(selectedRecord, fieldNames); + + await this.context.runStore.saveStepExecution({ + type: 'ai-task', + stepIndex: stepHistory.stepIndex, + executionParams: { fieldName: fieldNames }, + executionResult: { fields: fieldResults }, + selectedRecordRef: { + recordId: selectedRecord.recordId, + collectionName: selectedRecord.collectionName, + collectionDisplayName: selectedRecord.collectionDisplayName, + fields: selectedRecord.fields, + }, + }); + + return { stepHistory: { ...stepHistory, status: 'success' } }; + } + + private async selectFields( + record: RecordData, + prompt: string | undefined, + ): Promise { + const tool = this.buildReadFieldTool(record); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(READ_RECORD_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${record.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Read the relevant fields.'}`), + ]; + + const args = await this.invokeWithTool<{ fieldName: string | string[] }>(messages, tool); + + return args.fieldName; + } + + private async selectRecord( + records: RecordData[], + prompt: string | undefined, + ): Promise { + if (records.length === 0) throw new NoRecordsError(); + if (records.length === 1) return records[0]; + + const identifiers = records.map(r => `${r.collectionDisplayName} #${r.recordId}`) 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(identifiers), + }), + func: async input => JSON.stringify(input), + }); + + 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 selected = records.find( + r => `${r.collectionDisplayName} #${r.recordId}` === recordIdentifier, + ); + + if (!selected) { + throw new WorkflowExecutorError( + `AI selected record "${recordIdentifier}" which does not match any available record`, + ); + } + + return selected; + } + + private buildReadFieldTool(record: RecordData): DynamicStructuredTool { + const nonRelationFields = record.fields.filter(f => !f.isRelationship); + + if (nonRelationFields.length === 0) { + throw new NoReadableFieldsError(record.collectionName); + } + + const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; + const fieldNames = nonRelationFields.map(f => f.fieldName) as [string, ...string[]]; + + return new DynamicStructuredTool({ + name: 'read-selected-record-field', + description: 'Read one or more fields from the selected record.', + schema: z.object({ + fieldName: z.union([ + z.union([z.enum(displayNames), z.enum(fieldNames)]).describe('Name of the field to get'), + // z.string() (not z.enum) intentionally: an invalid field name in the array + // does not fail the whole tool call — per-field errors are handled in readFieldValues. + // This matches the frontend implementation (ISO frontend). + z + .array(z.string()) + .describe( + `Names of the fields to get, possible values are: ${displayNames + .map(n => `"${n}"`) + .join(', ')}`, + ), + ]), + }), + func: async input => JSON.stringify(input), + }); + } + + private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResult { + const names = Array.isArray(fieldName) ? fieldName : [fieldName]; + + return names.map(name => { + const field = record.fields.find(f => f.fieldName === name || f.displayName === name); + + if (!field) return { error: `Field not found: ${name}`, fieldName: name }; + + return { + value: record.values[field.fieldName], + fieldName: field.fieldName, + displayName: field.displayName, + }; + }); + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 2918b36c4..e315b9023 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -38,8 +38,11 @@ export { MissingToolCallError, MalformedToolCallError, RecordNotFoundError, + NoRecordsError, + NoReadableFieldsError, } 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 AgentClientAgentPort } from './adapters/agent-client-agent-port'; export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index dffae8c31..b01767671 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -11,23 +11,18 @@ export enum StepType { interface BaseStepDefinition { id: string; type: StepType; + prompt?: string; aiConfigName?: string; } export interface ConditionStepDefinition extends BaseStepDefinition { type: StepType.Condition; options: [string, ...string[]]; - prompt?: string; } export interface AiTaskStepDefinition extends BaseStepDefinition { - type: - | StepType.ReadRecord - | StepType.UpdateRecord - | StepType.TriggerAction - | StepType.LoadRelatedRecord; + type: Exclude; recordSourceStepId?: string; - prompt?: string; automaticCompletion?: boolean; allowedTools?: string[]; remoteToolsSourceId?: string; 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 new file mode 100644 index 000000000..efa5f27b6 --- /dev/null +++ b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts @@ -0,0 +1,571 @@ +import type { RunStore } from '../../src/ports/run-store'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { RecordData } from '../../src/types/record'; +import type { AiTaskStepDefinition } from '../../src/types/step-definition'; +import type { AiTaskStepHistory } from '../../src/types/step-history'; + +import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): AiTaskStepDefinition { + return { + id: 'read-1', + type: StepType.ReadRecord, + prompt: 'Read the customer email', + ...overrides, + }; +} + +function makeStepHistory(overrides: Partial = {}): AiTaskStepHistory { + return { + type: 'ai-task', + stepId: 'read-1', + stepIndex: 0, + status: 'success', + ...overrides, + }; +} + +function makeRecord(overrides: Partial = {}): RecordData { + return { + recordId: '42', + collectionName: 'customers', + collectionDisplayName: 'Customers', + fields: [ + { fieldName: 'email', displayName: 'Email', type: 'String', isRelationship: false }, + { fieldName: 'name', displayName: 'Full Name', type: 'String', isRelationship: false }, + { + fieldName: 'orders', + displayName: 'Orders', + type: 'HasMany', + isRelationship: true, + referencedCollectionName: 'orders', + }, + ], + values: { + email: 'john@example.com', + name: 'John Doe', + orders: null, + }, + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getRecords: jest.fn().mockResolvedValue([makeRecord()]), + getRecord: jest.fn().mockResolvedValue(null), + saveRecord: jest.fn().mockResolvedValue(undefined), + getStepExecutions: jest.fn().mockResolvedValue([]), + getStepExecution: jest.fn().mockResolvedValue(null), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockModel( + toolCallArgs?: Record, + toolName = 'read-selected-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', + model: makeMockModel({ fieldName: 'email' }).model, + agentPort: {} as ExecutionContext['agentPort'], + workflowPort: {} as ExecutionContext['workflowPort'], + runStore: makeMockRunStore(), + history: [], + remoteTools: [], + ...overrides, + }; +} + +describe('ReadRecordStepExecutor', () => { + describe('single record, single field', () => { + it('reads a single field and returns success', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ai-task', + stepIndex: 0, + executionParams: { fieldName: 'email' }, + executionResult: { + fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + }, + }), + ); + }); + }); + + describe('single record, multiple fields', () => { + it('reads multiple fields in one call and returns success', async () => { + const mockModel = makeMockModel({ fieldName: ['email', 'name'] }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionParams: { fieldName: ['email', 'name'] }, + executionResult: { + fields: [ + { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, + { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + ], + }, + }), + ); + }); + }); + + describe('field resolution by displayName', () => { + it('resolves fields by displayName', async () => { + const mockModel = makeMockModel({ fieldName: 'Full Name' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionResult: { + fields: [{ value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }], + }, + }), + ); + }); + }); + + describe('field not found', () => { + it('returns error per field without failing globally', async () => { + const mockModel = makeMockModel({ fieldName: ['email', 'nonexistent'] }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionResult: { + fields: [ + { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, + { error: 'Field not found: nonexistent', fieldName: 'nonexistent' }, + ], + }, + }), + ); + }); + }); + + describe('relationship fields excluded', () => { + it('excludes relationship fields from tool schema', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(makeStep(), makeStepHistory()); + + const tool = mockModel.bindTools.mock.calls[0][0][0]; + expect(tool.name).toBe('read-selected-record-field'); + + // 'Email' and 'Full Name' (displayNames) + 'email' and 'name' (fieldNames) should be valid + expect(tool.schema.parse({ fieldName: 'Email' })).toBeTruthy(); + expect(tool.schema.parse({ fieldName: 'Full Name' })).toBeTruthy(); + expect(tool.schema.parse({ fieldName: 'email' })).toBeTruthy(); + expect(tool.schema.parse({ fieldName: 'name' })).toBeTruthy(); + + // 'Orders' (relationship) is excluded from the single-value enum + expect(() => tool.schema.parse({ fieldName: 'Orders' })).toThrow(); + }); + }); + + describe('no readable fields', () => { + it('returns error when all fields are relationships', async () => { + const record = makeRecord({ + fields: [ + { + fieldName: 'orders', + displayName: 'Orders', + type: 'HasMany', + isRelationship: true, + referencedCollectionName: 'orders', + }, + ], + }); + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record]), + }); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe( + 'No readable fields on record from collection "customers"', + ); + }); + }); + + describe('multi-record AI selection', () => { + it('uses AI to select among multiple records then reads fields', async () => { + const record1 = makeRecord(); + const record2 = makeRecord({ + recordId: '99', + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, + ], + values: { total: 150 }, + }); + + // First call: select-record, second call: read-selected-record-field + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Customers #42' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'read-selected-record-field', + args: { fieldName: 'email' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record1, record2]), + }); + const context = makeContext({ model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(bindTools).toHaveBeenCalledTimes(2); + + // First call: select-record tool + const selectTool = bindTools.mock.calls[0][0][0]; + expect(selectTool.name).toBe('select-record'); + + // Second call: read-selected-record-field tool + const readTool = bindTools.mock.calls[1][0][0]; + expect(readTool.name).toBe('read-selected-record-field'); + + // Record selection includes previous steps context + system prompt + user prompt + const selectMessages = invoke.mock.calls[0][0]; + expect(selectMessages).toHaveLength(2); + expect(selectMessages[0].content).toContain('selecting the most relevant record'); + expect(selectMessages[1].content).toContain('Read the customer email'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionResult: { + fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + }, + selectedRecordRef: expect.objectContaining({ + recordId: '42', + collectionName: 'customers', + }), + }), + ); + }); + + it('reads fields from the second record when AI selects it', async () => { + const record1 = makeRecord(); + const record2 = makeRecord({ + recordId: '99', + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, + ], + values: { total: 150 }, + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'select-record', args: { recordIdentifier: 'Orders #99' }, id: 'call_1' }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'read-selected-record-field', args: { fieldName: 'total' }, id: 'call_2' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record1, record2]), + }); + const context = makeContext({ model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + expect.objectContaining({ + executionResult: { + fields: [{ value: 150, fieldName: 'total', displayName: 'Total' }], + }, + selectedRecordRef: expect.objectContaining({ + recordId: '99', + collectionName: 'orders', + }), + }), + ); + }); + }); + + describe('no records available', () => { + it('returns error when no records exist', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([]), + }); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe('No records available'); + }); + }); + + describe('model error', () => { + it('returns error status when AI invocation fails', 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 ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe('API timeout'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('malformed tool call', () => { + it('returns error status on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'read-selected-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 ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe( + 'AI returned a malformed tool call for "read-selected-record-field": JSON parse error', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error status when AI returns no tool call at all', 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 ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe('AI did not return a tool call'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('RunStore error propagation', () => { + it('lets saveStepExecution errors propagate', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), + }); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('Storage full'); + }); + + it('lets getRecords errors propagate', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockRejectedValue(new Error('Connection lost')), + }); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow( + 'Connection lost', + ); + }); + }); + + describe('immutability', () => { + it('does not mutate the input stepHistory', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const stepHistory = makeStepHistory(); + const context = makeContext({ model: mockModel.model }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), stepHistory); + + expect(result.stepHistory).not.toBe(stepHistory); + expect(stepHistory.status).toBe('success'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in read-field messages', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + history: [ + { + step: { + id: 'prev-step', + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepHistory: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute( + makeStep({ id: 'read-2' }), + makeStepHistory({ stepId: 'read-2', stepIndex: 1 }), + ); + + 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('reading fields from a record'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ fieldName: 'email' }); + const context = makeContext({ model: mockModel.model }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(makeStep({ prompt: undefined }), makeStepHistory()); + + const messages = mockModel.invoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Read the relevant fields.'); + }); + }); + + describe('saveStepExecution arguments', () => { + it('saves executionParams, executionResult, and selectedRecordRef', async () => { + const record = makeRecord(); + const mockModel = makeMockModel({ fieldName: ['email', 'name'] }); + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record]), + }); + const context = makeContext({ model: mockModel.model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(makeStep(), makeStepHistory({ stepIndex: 3 })); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith({ + type: 'ai-task', + stepIndex: 3, + executionParams: { fieldName: ['email', 'name'] }, + executionResult: { + fields: [ + { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, + { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + ], + }, + selectedRecordRef: { + recordId: '42', + collectionName: 'customers', + collectionDisplayName: 'Customers', + fields: record.fields, + }, + }); + }); + }); +}); From 14b4783bb6ae5b976ac3360daa8fe0e2a9b4f7a8 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:02:46 +0100 Subject: [PATCH 02/37] refactor(workflow-executor): apply review suggestions on ReadRecordStepExecutor - Extract toRecordIdentifier helper to avoid duplicate template string - Add test: AI selects non-existent record identifier - Add saveStepExecution not-called assertions to no-records and no-readable-fields tests Co-Authored-By: Claude Sonnet 4.6 --- .../executors/read-record-step-executor.ts | 13 +++---- .../read-record-step-executor.test.ts | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 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 6157fb10f..73b170624 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -23,6 +23,10 @@ interface FieldReadError { type FieldReadResult = (FieldReadSuccess | FieldReadError)[]; +function toRecordIdentifier(record: RecordData): string { + return `${record.collectionDisplayName} #${record.recordId}`; +} + 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. @@ -95,10 +99,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; - const identifiers = records.map(r => `${r.collectionDisplayName} #${r.recordId}`) as [ - string, - ...string[], - ]; + const identifiers = records.map(toRecordIdentifier) as [string, ...string[]]; const tool = new DynamicStructuredTool({ name: 'select-record', @@ -124,9 +125,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< tool, ); - const selected = records.find( - r => `${r.collectionDisplayName} #${r.recordId}` === recordIdentifier, - ); + const selected = records.find(r => toRecordIdentifier(r) === recordIdentifier); if (!selected) { throw new WorkflowExecutorError( 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 efa5f27b6..1a46724d6 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 @@ -229,6 +229,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.error).toBe( 'No readable fields on record from collection "customers"', ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -357,6 +358,43 @@ describe('ReadRecordStepExecutor', () => { }); }); + describe('AI record selection failure', () => { + it('returns error when AI selects a non-existent record identifier', async () => { + const record1 = makeRecord(); + const record2 = makeRecord({ + recordId: '99', + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, + ], + values: { total: 150 }, + }); + + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { name: 'select-record', args: { recordIdentifier: 'NonExistent #999' }, id: 'call_1' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record1, record2]), + }); + const context = makeContext({ model, runStore }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe( + 'AI selected record "NonExistent #999" which does not match any available record', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + describe('no records available', () => { it('returns error when no records exist', async () => { const mockModel = makeMockModel({ fieldName: 'email' }); @@ -370,6 +408,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe('No records available'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); From ecbd2340b8061b11ae1c4fe91ef92dd27d4cb4f9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:06:40 +0100 Subject: [PATCH 03/37] docs: document feat/prd-214 as main working branch for workflow-executor Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 238598e1f..138e9daaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,11 @@ yarn workspace @forestadmin/agent test 5. Are edge cases handled? 6. Is the naming clear and consistent? +## Git Workflow + +The **main working branch** for workflow-executor development is `feat/prd-214-setup-workflow-executor-package`. +All feature branches for this area should be based on and PRs targeted at this branch (not `main`). + ## Linear Tickets ### MCP Setup From a6af52169edd55f16e16fa2843ba604ce7b20a34 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:14:00 +0100 Subject: [PATCH 04/37] fix(workflow-executor): add displayName to FieldReadError for interface symmetry Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/read-record-step-executor.ts | 3 ++- .../test/executors/read-record-step-executor.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 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 73b170624..ee8b59718 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -19,6 +19,7 @@ interface FieldReadSuccess { interface FieldReadError { error: string; fieldName: string; + displayName: string; } type FieldReadResult = (FieldReadSuccess | FieldReadError)[]; @@ -174,7 +175,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return names.map(name => { const field = record.fields.find(f => f.fieldName === name || f.displayName === name); - if (!field) return { error: `Field not found: ${name}`, fieldName: name }; + if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; return { value: record.values[field.fieldName], 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 1a46724d6..f9ddd0b5d 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 @@ -172,7 +172,7 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [ { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { error: 'Field not found: nonexistent', fieldName: 'nonexistent' }, + { error: 'Field not found: nonexistent', fieldName: 'nonexistent', displayName: 'nonexistent' }, ], }, }), From 2b3f2b68ad9e984600acfc5b287ac364160abaab Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:14:59 +0100 Subject: [PATCH 05/37] refactor(workflow-executor): unify FieldReadSuccess/FieldReadError into single FieldReadResult Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/read-record-step-executor.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 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 ee8b59718..85c4fedba 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -10,19 +10,14 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoRecordsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -interface FieldReadSuccess { - value: unknown; +interface FieldReadResult { fieldName: string; displayName: string; + value?: unknown; + error?: string; } -interface FieldReadError { - error: string; - fieldName: string; - displayName: string; -} - -type FieldReadResult = (FieldReadSuccess | FieldReadError)[]; +type FieldReadResults = FieldReadResult[]; function toRecordIdentifier(record: RecordData): string { return `${record.collectionDisplayName} #${record.recordId}`; @@ -169,7 +164,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResult { + private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResults { const names = Array.isArray(fieldName) ? fieldName : [fieldName]; return names.map(name => { From 9d912a9e1b04627d89da006fd76657e7dc7034b0 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:18:03 +0100 Subject: [PATCH 06/37] refactor(workflow-executor): extract FieldReadBase to share common fields across FieldRead types Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/read-record-step-executor.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 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 85c4fedba..d34e4aeec 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -10,13 +10,20 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoRecordsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -interface FieldReadResult { +interface FieldReadBase { fieldName: string; displayName: string; - value?: unknown; - error?: string; } +interface FieldReadSuccess extends FieldReadBase { + value: unknown; +} + +interface FieldReadError extends FieldReadBase { + error: string; +} + +type FieldReadResult = FieldReadSuccess | FieldReadError; type FieldReadResults = FieldReadResult[]; function toRecordIdentifier(record: RecordData): string { @@ -164,7 +171,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResults { + private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResult[] { const names = Array.isArray(fieldName) ? fieldName : [fieldName]; return names.map(name => { From 0dfb8573fcfc91b4ea591fffd11bcae0db9e1c1d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:21:32 +0100 Subject: [PATCH 07/37] refactor(workflow-executor): move toRecordIdentifier to private static method Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 12 ++++++------ 1 file changed, 6 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 d34e4aeec..d85ebf040 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -26,10 +26,6 @@ interface FieldReadError extends FieldReadBase { type FieldReadResult = FieldReadSuccess | FieldReadError; type FieldReadResults = FieldReadResult[]; -function toRecordIdentifier(record: RecordData): string { - return `${record.collectionDisplayName} #${record.recordId}`; -} - 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. @@ -102,7 +98,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; - const identifiers = records.map(toRecordIdentifier) as [string, ...string[]]; + const identifiers = records.map(ReadRecordStepExecutor.toRecordIdentifier) as [string, ...string[]]; const tool = new DynamicStructuredTool({ name: 'select-record', @@ -128,7 +124,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< tool, ); - const selected = records.find(r => toRecordIdentifier(r) === recordIdentifier); + const selected = records.find(r => ReadRecordStepExecutor.toRecordIdentifier(r) === recordIdentifier); if (!selected) { throw new WorkflowExecutorError( @@ -186,4 +182,8 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }; }); } + + private static toRecordIdentifier(record: RecordData): string { + return `${record.collectionDisplayName} #${record.recordId}`; + } } From f1743955c273ebb3b47280f595844efa4d4f4f4a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:40:58 +0100 Subject: [PATCH 08/37] feat(workflow-executor): include step index in record identifier Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 13 +++-- .../workflow-executor/src/types/record.ts | 1 + .../read-record-step-executor.test.ts | 54 +++++++++++++++++-- 3 files changed, 61 insertions(+), 7 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 d85ebf040..fdb0eecbb 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -98,7 +98,10 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; - const identifiers = records.map(ReadRecordStepExecutor.toRecordIdentifier) as [string, ...string[]]; + const identifiers = records.map(ReadRecordStepExecutor.toRecordIdentifier) as [ + string, + ...string[], + ]; const tool = new DynamicStructuredTool({ name: 'select-record', @@ -124,7 +127,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< tool, ); - const selected = records.find(r => ReadRecordStepExecutor.toRecordIdentifier(r) === recordIdentifier); + const selected = records.find( + r => ReadRecordStepExecutor.toRecordIdentifier(r) === recordIdentifier, + ); if (!selected) { throw new WorkflowExecutorError( @@ -184,6 +189,8 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< } private static toRecordIdentifier(record: RecordData): string { - return `${record.collectionDisplayName} #${record.recordId}`; + const stepPrefix = record.stepIndex !== undefined ? `Step ${record.stepIndex} - ` : ''; + + return `${stepPrefix}${record.collectionDisplayName} #${record.recordId}`; } } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 14064fcb1..5486072c4 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -23,5 +23,6 @@ export interface CollectionRef { export interface RecordData extends CollectionRef { recordId: Array; + stepIndex: number; values: Record; } 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 f9ddd0b5d..d81be9d18 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 @@ -235,8 +235,9 @@ describe('ReadRecordStepExecutor', () => { describe('multi-record AI selection', () => { it('uses AI to select among multiple records then reads fields', async () => { - const record1 = makeRecord(); + const record1 = makeRecord({ stepIndex: 1 }); const record2 = makeRecord({ + stepIndex: 2, recordId: '99', collectionName: 'orders', collectionDisplayName: 'Orders', @@ -253,7 +254,7 @@ describe('ReadRecordStepExecutor', () => { tool_calls: [ { name: 'select-record', - args: { recordIdentifier: 'Customers #42' }, + args: { recordIdentifier: 'Step 1 - Customers #42' }, id: 'call_1', }, ], @@ -309,8 +310,9 @@ describe('ReadRecordStepExecutor', () => { }); it('reads fields from the second record when AI selects it', async () => { - const record1 = makeRecord(); + const record1 = makeRecord({ stepIndex: 1 }); const record2 = makeRecord({ + stepIndex: 2, recordId: '99', collectionName: 'orders', collectionDisplayName: 'Orders', @@ -324,7 +326,7 @@ describe('ReadRecordStepExecutor', () => { .fn() .mockResolvedValueOnce({ tool_calls: [ - { name: 'select-record', args: { recordIdentifier: 'Orders #99' }, id: 'call_1' }, + { name: 'select-record', args: { recordIdentifier: 'Step 2 - Orders #99' }, id: 'call_1' }, ], }) .mockResolvedValueOnce({ @@ -356,6 +358,50 @@ describe('ReadRecordStepExecutor', () => { }), ); }); + + it('includes step index in select-record tool schema when records have stepIndex', async () => { + const record1 = makeRecord({ stepIndex: 3 }); + const record2 = makeRecord({ + stepIndex: 5, + recordId: '99', + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, + ], + values: { total: 150 }, + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'select-record', args: { recordIdentifier: 'Step 3 - Customers #42' }, id: 'call_1' }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'read-selected-record-field', args: { fieldName: 'email' }, id: 'call_2' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getRecords: jest.fn().mockResolvedValue([record1, record2]), + }); + const executor = new ReadRecordStepExecutor(makeContext({ model, runStore })); + + await executor.execute(makeStep(), makeStepHistory()); + + const selectTool = bindTools.mock.calls[0][0][0]; + const schemaShape = selectTool.schema.shape; + // Enum values should include step-prefixed identifiers + expect(schemaShape.recordIdentifier.options).toEqual([ + 'Step 3 - Customers #42', + 'Step 5 - Orders #99', + ]); + }); }); describe('AI record selection failure', () => { From 9e71c086bdb05544cf3bf31cb0006c26c75c3207 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:47:16 +0100 Subject: [PATCH 09/37] refactor(workflow-executor): make stepIndex required on RecordData Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 4 +--- .../test/executors/read-record-step-executor.test.ts | 1 + 2 files changed, 2 insertions(+), 3 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 fdb0eecbb..a6b83e476 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -189,8 +189,6 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< } private static toRecordIdentifier(record: RecordData): string { - const stepPrefix = record.stepIndex !== undefined ? `Step ${record.stepIndex} - ` : ''; - - return `${stepPrefix}${record.collectionDisplayName} #${record.recordId}`; + return `Step ${record.stepIndex} - ${record.collectionDisplayName} #${record.recordId}`; } } 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 d81be9d18..567d67463 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 @@ -28,6 +28,7 @@ function makeStepHistory(overrides: Partial = {}): AiTaskStep function makeRecord(overrides: Partial = {}): RecordData { return { + stepIndex: 0, recordId: '42', collectionName: 'customers', collectionDisplayName: 'Customers', From e2c9cf88e3af1bdd5c3f83e8df2caeef66b5e02c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 14:56:22 +0100 Subject: [PATCH 10/37] refactor(workflow-executor): rename fieldName to fieldNames and force array schema - Remove unused FieldReadResults type alias - Rename read-selected-record-field to read-selected-record-fields - fieldNames is now always string[] (no more single string union) Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 42 +++++------ .../read-record-step-executor.test.ts | 73 ++++++++++--------- 2 files changed, 56 insertions(+), 59 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 a6b83e476..527220f14 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -24,7 +24,6 @@ interface FieldReadError extends FieldReadBase { } type FieldReadResult = FieldReadSuccess | FieldReadError; -type FieldReadResults = FieldReadResult[]; 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. @@ -45,7 +44,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< const records = await this.context.runStore.getRecords(); let selectedRecord: RecordData; - let fieldNames: string | string[]; + let fieldNames: string[]; try { selectedRecord = await this.selectRecord(records, step.prompt); @@ -59,7 +58,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< await this.context.runStore.saveStepExecution({ type: 'ai-task', stepIndex: stepHistory.stepIndex, - executionParams: { fieldName: fieldNames }, + executionParams: { fieldNames }, executionResult: { fields: fieldResults }, selectedRecordRef: { recordId: selectedRecord.recordId, @@ -75,7 +74,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< private async selectFields( record: RecordData, prompt: string | undefined, - ): Promise { + ): Promise { const tool = this.buildReadFieldTool(record); const messages = [ ...(await this.buildPreviousStepsMessages()), @@ -86,9 +85,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< new HumanMessage(`**Request**: ${prompt ?? 'Read the relevant fields.'}`), ]; - const args = await this.invokeWithTool<{ fieldName: string | string[] }>(messages, tool); + const args = await this.invokeWithTool<{ fieldNames: string[] }>(messages, tool); - return args.fieldName; + return args.fieldNames; } private async selectRecord( @@ -151,31 +150,26 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< const fieldNames = nonRelationFields.map(f => f.fieldName) as [string, ...string[]]; return new DynamicStructuredTool({ - name: 'read-selected-record-field', + name: 'read-selected-record-fields', description: 'Read one or more fields from the selected record.', schema: z.object({ - fieldName: z.union([ - z.union([z.enum(displayNames), z.enum(fieldNames)]).describe('Name of the field to get'), - // z.string() (not z.enum) intentionally: an invalid field name in the array - // does not fail the whole tool call — per-field errors are handled in readFieldValues. - // This matches the frontend implementation (ISO frontend). - z - .array(z.string()) - .describe( - `Names of the fields to get, possible values are: ${displayNames - .map(n => `"${n}"`) - .join(', ')}`, - ), - ]), + // z.string() (not z.enum) intentionally: an invalid field name in the array + // does not fail the whole tool call — per-field errors are handled in readFieldValues. + // This matches the frontend implementation (ISO frontend). + fieldNames: z + .array(z.string()) + .describe( + `Names of the fields to read, possible values are: ${displayNames + .map(n => `"${n}"`) + .join(', ')}`, + ), }), func: async input => JSON.stringify(input), }); } - private readFieldValues(record: RecordData, fieldName: string | string[]): FieldReadResult[] { - const names = Array.isArray(fieldName) ? fieldName : [fieldName]; - - return names.map(name => { + private readFieldValues(record: RecordData, fieldNames: string[]): FieldReadResult[] { + return fieldNames.map(name => { const field = record.fields.find(f => f.fieldName === name || f.displayName === name); if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; 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 567d67463..ddc0fc38d 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 @@ -66,7 +66,7 @@ function makeMockRunStore(overrides: Partial = {}): RunStore { function makeMockModel( toolCallArgs?: Record, - toolName = 'read-selected-record-field', + toolName = 'read-selected-record-fields', ) { const invoke = jest.fn().mockResolvedValue({ tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, @@ -80,7 +80,7 @@ function makeMockModel( function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', - model: makeMockModel({ fieldName: 'email' }).model, + model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), @@ -93,7 +93,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex describe('ReadRecordStepExecutor', () => { describe('single record, single field', () => { it('reads a single field and returns success', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -105,7 +105,7 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ type: 'ai-task', stepIndex: 0, - executionParams: { fieldName: 'email' }, + executionParams: { fieldNames: ['email'] }, executionResult: { fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], }, @@ -116,7 +116,7 @@ describe('ReadRecordStepExecutor', () => { describe('single record, multiple fields', () => { it('reads multiple fields in one call and returns success', async () => { - const mockModel = makeMockModel({ fieldName: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -126,7 +126,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ - executionParams: { fieldName: ['email', 'name'] }, + executionParams: { fieldNames: ['email', 'name'] }, executionResult: { fields: [ { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, @@ -140,7 +140,7 @@ describe('ReadRecordStepExecutor', () => { describe('field resolution by displayName', () => { it('resolves fields by displayName', async () => { - const mockModel = makeMockModel({ fieldName: 'Full Name' }); + const mockModel = makeMockModel({ fieldNames: ['Full Name'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -160,7 +160,7 @@ describe('ReadRecordStepExecutor', () => { describe('field not found', () => { it('returns error per field without failing globally', async () => { - const mockModel = makeMockModel({ fieldName: ['email', 'nonexistent'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'nonexistent'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -183,7 +183,7 @@ describe('ReadRecordStepExecutor', () => { describe('relationship fields excluded', () => { it('excludes relationship fields from tool schema', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -191,16 +191,19 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(makeStep(), makeStepHistory()); const tool = mockModel.bindTools.mock.calls[0][0][0]; - expect(tool.name).toBe('read-selected-record-field'); + expect(tool.name).toBe('read-selected-record-fields'); - // 'Email' and 'Full Name' (displayNames) + 'email' and 'name' (fieldNames) should be valid - expect(tool.schema.parse({ fieldName: 'Email' })).toBeTruthy(); - expect(tool.schema.parse({ fieldName: 'Full Name' })).toBeTruthy(); - expect(tool.schema.parse({ fieldName: 'email' })).toBeTruthy(); - expect(tool.schema.parse({ fieldName: 'name' })).toBeTruthy(); + // 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(); - // 'Orders' (relationship) is excluded from the single-value enum - expect(() => tool.schema.parse({ fieldName: 'Orders' })).toThrow(); + // Schema accepts any strings (per-field errors handled in readFieldValues, ISO frontend) + expect(tool.schema.parse({ fieldNames: ['Orders'] })).toBeTruthy(); + + // But rejects non-array values + expect(() => tool.schema.parse({ fieldNames: 'email' })).toThrow(); }); }); @@ -217,7 +220,7 @@ describe('ReadRecordStepExecutor', () => { }, ], }); - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record]), }); @@ -248,7 +251,7 @@ describe('ReadRecordStepExecutor', () => { values: { total: 150 }, }); - // First call: select-record, second call: read-selected-record-field + // First call: select-record, second call: read-selected-record-fields const invoke = jest .fn() .mockResolvedValueOnce({ @@ -263,8 +266,8 @@ describe('ReadRecordStepExecutor', () => { .mockResolvedValueOnce({ tool_calls: [ { - name: 'read-selected-record-field', - args: { fieldName: 'email' }, + name: 'read-selected-record-fields', + args: { fieldNames: ['email'] }, id: 'call_2', }, ], @@ -287,9 +290,9 @@ describe('ReadRecordStepExecutor', () => { const selectTool = bindTools.mock.calls[0][0][0]; expect(selectTool.name).toBe('select-record'); - // Second call: read-selected-record-field tool + // Second call: read-selected-record-fields tool const readTool = bindTools.mock.calls[1][0][0]; - expect(readTool.name).toBe('read-selected-record-field'); + expect(readTool.name).toBe('read-selected-record-fields'); // Record selection includes previous steps context + system prompt + user prompt const selectMessages = invoke.mock.calls[0][0]; @@ -332,7 +335,7 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-field', args: { fieldName: 'total' }, id: 'call_2' }, + { name: 'read-selected-record-fields', args: { fieldNames: ['total'] }, id: 'call_2' }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -382,7 +385,7 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-field', args: { fieldName: 'email' }, id: 'call_2' }, + { name: 'read-selected-record-fields', args: { fieldNames: ['email'] }, id: 'call_2' }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -444,7 +447,7 @@ describe('ReadRecordStepExecutor', () => { describe('no records available', () => { it('returns error when no records exist', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([]), }); @@ -483,7 +486,7 @@ describe('ReadRecordStepExecutor', () => { const invoke = jest.fn().mockResolvedValue({ tool_calls: [], invalid_tool_calls: [ - { name: 'read-selected-record-field', args: '{bad json', error: 'JSON parse error' }, + { name: 'read-selected-record-fields', args: '{bad json', error: 'JSON parse error' }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -498,7 +501,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe( - 'AI returned a malformed tool call for "read-selected-record-field": JSON parse error', + 'AI returned a malformed tool call for "read-selected-record-fields": JSON parse error', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -523,7 +526,7 @@ describe('ReadRecordStepExecutor', () => { describe('RunStore error propagation', () => { it('lets saveStepExecution errors propagate', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), }); @@ -534,7 +537,7 @@ describe('ReadRecordStepExecutor', () => { }); it('lets getRecords errors propagate', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getRecords: jest.fn().mockRejectedValue(new Error('Connection lost')), }); @@ -549,7 +552,7 @@ describe('ReadRecordStepExecutor', () => { describe('immutability', () => { it('does not mutate the input stepHistory', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const stepHistory = makeStepHistory(); const context = makeContext({ model: mockModel.model }); const executor = new ReadRecordStepExecutor(context); @@ -563,7 +566,7 @@ describe('ReadRecordStepExecutor', () => { describe('previous steps context', () => { it('includes previous steps summary in read-field messages', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -611,7 +614,7 @@ describe('ReadRecordStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { - const mockModel = makeMockModel({ fieldName: 'email' }); + const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model }); const executor = new ReadRecordStepExecutor(context); @@ -626,7 +629,7 @@ describe('ReadRecordStepExecutor', () => { describe('saveStepExecution arguments', () => { it('saves executionParams, executionResult, and selectedRecordRef', async () => { const record = makeRecord(); - const mockModel = makeMockModel({ fieldName: ['email', 'name'] }); + const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record]), }); @@ -638,7 +641,7 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith({ type: 'ai-task', stepIndex: 3, - executionParams: { fieldName: ['email', 'name'] }, + executionParams: { fieldNames: ['email', 'name'] }, executionResult: { fields: [ { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, From 2fade25b5d0400dbc78d10d7655978e04264da58 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 15:02:18 +0100 Subject: [PATCH 11/37] refactor(workflow-executor): type ReadRecordStepExecutionData precisely - Move FieldReadResult types from executor to step-execution-data.ts - Add ReadRecordStepExecutionData with typed executionParams/executionResult - Use type: 'read-record' discriminator instead of generic 'ai-task' - Export new types from index.ts Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 18 ++----------- packages/workflow-executor/src/index.ts | 4 +++ .../src/types/step-execution-data.ts | 27 ++++++++++++++++++- .../read-record-step-executor.test.ts | 4 +-- 4 files changed, 34 insertions(+), 19 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 527220f14..d48c1e6b5 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,6 +1,7 @@ import type { StepExecutionResult } from '../types/execution'; import type { RecordData } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; +import type { FieldReadResult } from '../types/step-execution-data'; import type { AiTaskStepHistory } from '../types/step-history'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -10,21 +11,6 @@ import { z } from 'zod'; import { NoReadableFieldsError, NoRecordsError, WorkflowExecutorError } from '../errors'; import BaseStepExecutor from './base-step-executor'; -interface FieldReadBase { - fieldName: string; - displayName: string; -} - -interface FieldReadSuccess extends FieldReadBase { - value: unknown; -} - -interface FieldReadError extends FieldReadBase { - error: string; -} - -type FieldReadResult = FieldReadSuccess | FieldReadError; - 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. @@ -56,7 +42,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< const fieldResults = this.readFieldValues(selectedRecord, fieldNames); await this.context.runStore.saveStepExecution({ - type: 'ai-task', + type: 'read-record', stepIndex: stepHistory.stepIndex, executionParams: { fieldNames }, executionResult: { fields: fieldResults }, diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index e315b9023..9d08fe601 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -14,7 +14,11 @@ export type { } from './types/step-history'; export type { + FieldReadSuccess, + FieldReadError, + FieldReadResult, ConditionStepExecutionData, + ReadRecordStepExecutionData, AiTaskStepExecutionData, StepExecutionData, } from './types/step-execution-data'; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index e2d46eaf4..dfe27e2ca 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -12,6 +12,28 @@ export interface ConditionStepExecutionData extends BaseStepExecutionData { executionResult?: { answer: string }; } +interface FieldReadBase { + fieldName: string; + displayName: string; +} + +export interface FieldReadSuccess extends FieldReadBase { + value: unknown; +} + +export interface FieldReadError extends FieldReadBase { + error: string; +} + +export type FieldReadResult = FieldReadSuccess | FieldReadError; + +export interface ReadRecordStepExecutionData extends BaseStepExecutionData { + type: 'read-record'; + executionParams: { fieldNames: string[] }; + executionResult: { fields: FieldReadResult[] }; + selectedRecordRef: RecordRef; +} + export interface AiTaskStepExecutionData extends BaseStepExecutionData { type: 'ai-task'; executionParams?: Record; @@ -20,4 +42,7 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { selectedRecord?: CollectionRef; } -export type StepExecutionData = ConditionStepExecutionData | AiTaskStepExecutionData; +export type StepExecutionData = + | ConditionStepExecutionData + | ReadRecordStepExecutionData + | AiTaskStepExecutionData; 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 ddc0fc38d..aa765adc7 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 @@ -103,7 +103,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ - type: 'ai-task', + type: 'read-record', stepIndex: 0, executionParams: { fieldNames: ['email'] }, executionResult: { @@ -639,7 +639,7 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(makeStep(), makeStepHistory({ stepIndex: 3 })); expect(runStore.saveStepExecution).toHaveBeenCalledWith({ - type: 'ai-task', + type: 'read-record', stepIndex: 3, executionParams: { fieldNames: ['email', 'name'] }, executionResult: { From 2e7274194781f3ca6efe3b06c2786dd1a82b720a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 15:07:21 +0100 Subject: [PATCH 12/37] refactor(workflow-executor): make ConditionStepExecutionData fields required - executionParams and executionResult are now required (align with ReadRecord) - Reorganize step-execution-data.ts by section (Condition, ReadRecord, Generic) - Fix base-step-executor tests for required fields Co-Authored-By: Claude Opus 4.6 --- .../src/types/step-execution-data.ts | 14 ++++++++++++-- .../test/executors/base-step-executor.test.ts | 8 ++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index dfe27e2ca..0427aa4ef 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -2,16 +2,22 @@ import type { CollectionRef } from './record'; +// -- Base -- + interface BaseStepExecutionData { stepIndex: number; } +// -- Condition -- + export interface ConditionStepExecutionData extends BaseStepExecutionData { type: 'condition'; - executionParams?: { answer: string | null; reasoning?: string }; - executionResult?: { answer: string }; + executionParams: { answer: string | null; reasoning?: string }; + executionResult: { answer: string }; } +// -- Read Record -- + interface FieldReadBase { fieldName: string; displayName: string; @@ -34,6 +40,8 @@ export interface ReadRecordStepExecutionData extends BaseStepExecutionData { selectedRecordRef: RecordRef; } +// -- Generic AI Task (fallback for untyped steps) -- + export interface AiTaskStepExecutionData extends BaseStepExecutionData { type: 'ai-task'; executionParams?: Record; @@ -42,6 +50,8 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { selectedRecord?: CollectionRef; } +// -- Union -- + export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData 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 73f5e716b..83724525b 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -103,19 +103,20 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Result: {"answer":"Yes","reasoning":"Order is valid"}'); }); - it('falls back to History when step has no executionParams in RunStore', async () => { + it('falls back to History when no matching step execution in RunStore', async () => { const executor = new TestableExecutor( makeContext({ history: [ 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: 0 }, { type: 'condition', stepIndex: 1, executionParams: { answer: 'No', reasoning: 'Clearly no' }, + executionResult: { answer: 'No' }, }, ]), }), @@ -143,6 +144,7 @@ describe('BaseStepExecutor', () => { type: 'condition', stepIndex: 1, executionParams: { answer: 'B', reasoning: 'Option B fits' }, + executionResult: { answer: 'B' }, }, ]), }), @@ -274,6 +276,7 @@ describe('BaseStepExecutor', () => { type: 'condition', stepIndex: 0, executionParams: { answer: 'A', reasoning: 'Best fit' }, + executionResult: { answer: 'A' }, }, ]), }), @@ -299,6 +302,7 @@ describe('BaseStepExecutor', () => { type: 'condition', stepIndex: 0, executionParams: { answer: 'A', reasoning: 'Only option' }, + executionResult: { answer: 'A' }, }, ]), }), From bb425641bd326dc77da356f316b1dd4f2633210d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 15:14:08 +0100 Subject: [PATCH 13/37] fix(workflow-executor): fix lint errors from CI - Remove unused fieldNames variable in buildReadFieldTool - Fix prettier formatting - Fix duplicate test title in base-step-executor Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 6 +----- .../test/executors/base-step-executor.test.ts | 2 +- .../read-record-step-executor.test.ts | 18 +++++++++++++++--- 3 files changed, 17 insertions(+), 9 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 d48c1e6b5..9f44ce695 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -57,10 +57,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return { stepHistory: { ...stepHistory, status: 'success' } }; } - private async selectFields( - record: RecordData, - prompt: string | undefined, - ): Promise { + private async selectFields(record: RecordData, prompt: string | undefined): Promise { const tool = this.buildReadFieldTool(record); const messages = [ ...(await this.buildPreviousStepsMessages()), @@ -133,7 +130,6 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< } const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; - const fieldNames = nonRelationFields.map(f => f.fieldName) as [string, ...string[]]; return new DynamicStructuredTool({ name: 'read-selected-record-fields', 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 83724525b..de5d7d572 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,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Result: {"answer":"Yes","reasoning":"Order is valid"}'); }); - it('falls back to History when no matching step execution in RunStore', async () => { + it('uses Result for matched steps and History for unmatched steps', async () => { const executor = new TestableExecutor( makeContext({ history: [ 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 aa765adc7..9d4f74a98 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 @@ -173,7 +173,11 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [ { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { error: 'Field not found: nonexistent', fieldName: 'nonexistent', displayName: 'nonexistent' }, + { + error: 'Field not found: nonexistent', + fieldName: 'nonexistent', + displayName: 'nonexistent', + }, ], }, }), @@ -330,7 +334,11 @@ describe('ReadRecordStepExecutor', () => { .fn() .mockResolvedValueOnce({ tool_calls: [ - { name: 'select-record', args: { recordIdentifier: 'Step 2 - Orders #99' }, id: 'call_1' }, + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, ], }) .mockResolvedValueOnce({ @@ -380,7 +388,11 @@ describe('ReadRecordStepExecutor', () => { .fn() .mockResolvedValueOnce({ tool_calls: [ - { name: 'select-record', args: { recordIdentifier: 'Step 3 - Customers #42' }, id: 'call_1' }, + { + name: 'select-record', + args: { recordIdentifier: 'Step 3 - Customers #42' }, + id: 'call_1', + }, ], }) .mockResolvedValueOnce({ From d668f3f25a56dffd3c1c3597df539836b0a8cc28 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 16:40:15 +0100 Subject: [PATCH 14/37] refactor(workflow-executor): separate schema types from record data types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the old CollectionRef → RecordData hierarchy into independent schema types (CollectionSchema, FieldSchema) and data types (RecordRef, RecordData). Schema is now sourced from WorkflowPort.getCollectionSchema, records from AgentPort/RunStore. Remove dead fields (type, referencedCollectionName) and dead code (availableRecords). Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/agent-client-agent-port.ts | 41 +++-- .../executors/read-record-step-executor.ts | 62 ++++--- packages/workflow-executor/src/index.ts | 8 +- .../workflow-executor/src/ports/agent-port.ts | 8 +- .../src/ports/workflow-port.ts | 6 +- .../workflow-executor/src/types/execution.ts | 2 - .../workflow-executor/src/types/record.ts | 21 ++- .../src/types/step-execution-data.ts | 5 +- .../adapters/agent-client-agent-port.test.ts | 49 ++---- .../read-record-step-executor.test.ts | 160 +++++++++++------- 10 files changed, 202 insertions(+), 160 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 23015e8bc..50b8f4596 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,5 +1,5 @@ import type { AgentPort } from '../ports/agent-port'; -import type { ActionRef, CollectionRef, RecordData } from '../types/record'; +import type { ActionRef, CollectionSchema } from '../types/record'; import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; @@ -36,20 +36,20 @@ function extractRecordId( export default class AgentClientAgentPort implements AgentPort { private readonly client: RemoteAgentClient; - private readonly collectionRefs: Record; + private readonly collectionSchemas: Record; constructor(params: { client: RemoteAgentClient; - collectionRefs: Record; + collectionSchemas: Record; }) { this.client = params.client; - this.collectionRefs = params.collectionRefs; + this.collectionSchemas = params.collectionSchemas; } - async getRecord(collectionName: string, recordId: Array): Promise { - const ref = this.getCollectionRef(collectionName); + async getRecord(collectionName: string, recordId: Array) { + const schema = this.resolveSchema(collectionName); const records = await this.client.collection(collectionName).list>({ - filters: buildPkFilter(ref.primaryKeyFields, recordId), + filters: buildPkFilter(schema.primaryKeyFields, recordId), pagination: { size: 1, number: 1 }, }); @@ -57,28 +57,27 @@ export default class AgentClientAgentPort implements AgentPort { throw new RecordNotFoundError(collectionName, encodePk(recordId)); } - return { ...ref, recordId, values: records[0] }; + return { collectionName, recordId, values: records[0] }; } async updateRecord( collectionName: string, recordId: Array, values: Record, - ): Promise { - const ref = this.getCollectionRef(collectionName); + ) { const updatedRecord = await this.client .collection(collectionName) .update>(encodePk(recordId), values); - return { ...ref, recordId, values: updatedRecord }; + return { collectionName, recordId, values: updatedRecord }; } async getRelatedData( collectionName: string, recordId: Array, relationName: string, - ): Promise { - const relatedRef = this.getCollectionRef(relationName); + ) { + const relatedSchema = this.resolveSchema(relationName); const records = await this.client .collection(collectionName) @@ -86,16 +85,16 @@ export default class AgentClientAgentPort implements AgentPort { .list>(); return records.map(record => ({ - ...relatedRef, - recordId: extractRecordId(relatedRef.primaryKeyFields, record), + collectionName: relatedSchema.collectionName, + recordId: extractRecordId(relatedSchema.primaryKeyFields, record), values: record, })); } async getActions(collectionName: string): Promise { - const ref = this.collectionRefs[collectionName]; + const schema = this.collectionSchemas[collectionName]; - return ref ? ref.actions : []; + return schema ? schema.actions : []; } async executeAction( @@ -111,10 +110,10 @@ export default class AgentClientAgentPort implements AgentPort { return action.execute(); } - private getCollectionRef(collectionName: string): CollectionRef { - const ref = this.collectionRefs[collectionName]; + private resolveSchema(collectionName: string): CollectionSchema { + const schema = this.collectionSchemas[collectionName]; - if (!ref) { + if (!schema) { return { collectionName, collectionDisplayName: collectionName, @@ -124,6 +123,6 @@ export default class AgentClientAgentPort implements AgentPort { }; } - return ref; + return schema; } } 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 9f44ce695..3f981a16a 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 { RecordData } from '../types/record'; +import type { CollectionSchema, RecordData } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult } from '../types/step-execution-data'; import type { AiTaskStepHistory } from '../types/step-history'; @@ -30,40 +30,44 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< const records = await this.context.runStore.getRecords(); let selectedRecord: RecordData; + let schema: CollectionSchema; let fieldNames: string[]; try { selectedRecord = await this.selectRecord(records, step.prompt); - fieldNames = await this.selectFields(selectedRecord, step.prompt); + schema = await this.context.workflowPort.getCollectionSchema(selectedRecord.collectionName); + fieldNames = await this.selectFields(schema, step.prompt); } catch (error) { return { stepHistory: { ...stepHistory, status: 'error', error: (error as Error).message } }; } - const fieldResults = this.readFieldValues(selectedRecord, fieldNames); + const fieldResults = this.readFieldValues(selectedRecord.values, schema, fieldNames); await this.context.runStore.saveStepExecution({ type: 'read-record', stepIndex: stepHistory.stepIndex, executionParams: { fieldNames }, executionResult: { fields: fieldResults }, - selectedRecordRef: { - recordId: selectedRecord.recordId, + selectedRecord: { collectionName: selectedRecord.collectionName, - collectionDisplayName: selectedRecord.collectionDisplayName, - fields: selectedRecord.fields, + recordId: selectedRecord.recordId, + stepIndex: selectedRecord.stepIndex, }, }); return { stepHistory: { ...stepHistory, status: 'success' } }; } - private async selectFields(record: RecordData, prompt: string | undefined): Promise { - const tool = this.buildReadFieldTool(record); + private async selectFields( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise { + const tool = this.buildReadFieldTool(schema); const messages = [ ...(await this.buildPreviousStepsMessages()), new SystemMessage(READ_RECORD_SYSTEM_PROMPT), new SystemMessage( - `The selected record belongs to the "${record.collectionDisplayName}" collection.`, + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, ), new HumanMessage(`**Request**: ${prompt ?? 'Read the relevant fields.'}`), ]; @@ -80,16 +84,14 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; - const identifiers = records.map(ReadRecordStepExecutor.toRecordIdentifier) as [ - string, - ...string[], - ]; + 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(identifiers), + recordIdentifier: z.enum(identifierTuple), }), func: async input => JSON.stringify(input), }); @@ -109,24 +111,22 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< tool, ); - const selected = records.find( - r => ReadRecordStepExecutor.toRecordIdentifier(r) === recordIdentifier, - ); + const selectedIndex = identifiers.indexOf(recordIdentifier); - if (!selected) { + if (selectedIndex === -1) { throw new WorkflowExecutorError( `AI selected record "${recordIdentifier}" which does not match any available record`, ); } - return selected; + return records[selectedIndex]; } - private buildReadFieldTool(record: RecordData): DynamicStructuredTool { - const nonRelationFields = record.fields.filter(f => !f.isRelationship); + private buildReadFieldTool(schema: CollectionSchema): DynamicStructuredTool { + const nonRelationFields = schema.fields.filter(f => !f.isRelationship); if (nonRelationFields.length === 0) { - throw new NoReadableFieldsError(record.collectionName); + throw new NoReadableFieldsError(schema.collectionName); } const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; @@ -150,21 +150,27 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private readFieldValues(record: RecordData, fieldNames: string[]): FieldReadResult[] { + private readFieldValues( + values: Record, + schema: CollectionSchema, + fieldNames: string[], + ): FieldReadResult[] { return fieldNames.map(name => { - const field = record.fields.find(f => f.fieldName === name || f.displayName === name); + const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; return { - value: record.values[field.fieldName], + value: values[field.fieldName], fieldName: field.fieldName, displayName: field.displayName, }; }); } - private static toRecordIdentifier(record: RecordData): string { - return `Step ${record.stepIndex} - ${record.collectionDisplayName} #${record.recordId}`; + private async toRecordIdentifier(record: RecordData): Promise { + const schema = await this.context.workflowPort.getCollectionSchema(record.collectionName); + + return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; } } diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 9d08fe601..314cf82f4 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -23,7 +23,13 @@ export type { StepExecutionData, } from './types/step-execution-data'; -export type { RecordFieldRef, ActionRef, CollectionRef, RecordData } from './types/record'; +export type { + FieldSchema, + ActionRef, + CollectionSchema, + RecordRef, + RecordData, +} from './types/record'; export type { StepRecord, diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 6a588f1f2..1f4d949e2 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -2,18 +2,20 @@ import type { ActionRef, RecordData } from '../types/record'; +type AgentRecord = Omit; + export interface AgentPort { - getRecord(collectionName: string, recordId: Array): Promise; + getRecord(collectionName: string, recordId: Array): Promise; updateRecord( collectionName: string, recordId: Array, values: Record, - ): Promise; + ): Promise; getRelatedData( collectionName: string, recordId: Array, relationName: string, - ): Promise; + ): Promise; getActions(collectionName: string): Promise; executeAction( collectionName: string, diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index 93951f6f0..c70a60c9f 100644 --- a/packages/workflow-executor/src/ports/workflow-port.ts +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -1,7 +1,7 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ import type { PendingStepExecution } from '../types/execution'; -import type { CollectionRef } from '../types/record'; +import type { CollectionSchema } from '../types/record'; import type { StepHistory } from '../types/step-history'; /** Placeholder -- will be typed as McpConfiguration from @forestadmin/ai-proxy/mcp-client once added as dependency. */ @@ -9,7 +9,7 @@ export type McpConfiguration = unknown; export interface WorkflowPort { getPendingStepExecutions(): Promise; - updateStepExecution(runId: string, stepHistory: StepHistory): Promise; - getCollectionRef(collectionName: string): Promise; + completeStepExecution(runId: string, stepHistory: StepHistory): Promise; + getCollectionSchema(collectionName: string): Promise; getMcpServerConfigs(): Promise; } diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index d2524403c..dd189c6ed 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -1,6 +1,5 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { CollectionRef } from './record'; import type { StepDefinition } from './step-definition'; import type { StepHistory } from './step-history'; import type { AgentPort } from '../ports/agent-port'; @@ -20,7 +19,6 @@ export interface PendingStepExecution { readonly step: StepDefinition; readonly stepHistory: StepHistory; readonly previousSteps: ReadonlyArray; - readonly availableRecords: ReadonlyArray; readonly userInput?: UserInput; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 5486072c4..dd95498aa 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -1,11 +1,11 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -export interface RecordFieldRef { +// -- Schema types (structure of a collection — source: WorkflowPort) -- + +export interface FieldSchema { fieldName: string; displayName: string; - type: string; isRelationship: boolean; - referencedCollectionName?: string; } export interface ActionRef { @@ -13,16 +13,25 @@ export interface ActionRef { displayName: string; } -export interface CollectionRef { +export interface CollectionSchema { collectionName: string; collectionDisplayName: string; primaryKeyFields: string[]; - fields: RecordFieldRef[]; + fields: FieldSchema[]; actions: ActionRef[]; } -export interface RecordData extends CollectionRef { +// -- Record types (data — source: AgentPort/RunStore) -- + +/** Lightweight pointer to a specific record. */ +export interface RecordRef { + collectionName: string; recordId: Array; + /** Index of the workflow step that loaded this record. */ stepIndex: number; +} + +/** A record with its loaded field values. */ +export interface RecordData extends RecordRef { values: Record; } diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 0427aa4ef..e690dfcb8 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { CollectionRef } from './record'; +import type { RecordRef } from './record'; // -- Base -- @@ -37,7 +37,7 @@ export interface ReadRecordStepExecutionData extends BaseStepExecutionData { type: 'read-record'; executionParams: { fieldNames: string[] }; executionResult: { fields: FieldReadResult[] }; - selectedRecordRef: RecordRef; + selectedRecord: RecordRef; } // -- Generic AI Task (fallback for untyped steps) -- @@ -47,7 +47,6 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { executionParams?: Record; executionResult?: Record; toolConfirmationInterruption?: Record; - selectedRecord?: CollectionRef; } // -- 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 878990787..047e09200 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 @@ -1,4 +1,4 @@ -import type { CollectionRef } from '../../src/types/record'; +import type { CollectionSchema } from '../../src/types/record'; import type { RemoteAgentClient } from '@forestadmin/agent-client'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; @@ -26,7 +26,7 @@ describe('AgentClientAgentPort', () => { let mockCollection: ReturnType['mockCollection']; let mockRelation: ReturnType['mockRelation']; let mockAction: ReturnType['mockAction']; - let collectionRefs: Record; + let collectionSchemas: Record; let port: AgentClientAgentPort; beforeEach(() => { @@ -34,14 +34,14 @@ describe('AgentClientAgentPort', () => { ({ client, mockCollection, mockRelation, mockAction } = createMockClient()); - collectionRefs = { + collectionSchemas = { users: { collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], fields: [ - { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, - { fieldName: 'name', displayName: 'name', type: 'String', isRelationship: false }, + { fieldName: 'id', displayName: 'id', isRelationship: false }, + { fieldName: 'name', displayName: 'name', isRelationship: false }, ], actions: [ { name: 'sendEmail', displayName: 'Send Email' }, @@ -53,8 +53,8 @@ describe('AgentClientAgentPort', () => { collectionDisplayName: 'Orders', primaryKeyFields: ['tenantId', 'orderId'], fields: [ - { fieldName: 'tenantId', displayName: 'Tenant', type: 'Number', isRelationship: false }, - { fieldName: 'orderId', displayName: 'Order', type: 'Number', isRelationship: false }, + { fieldName: 'tenantId', displayName: 'Tenant', isRelationship: false }, + { fieldName: 'orderId', displayName: 'Order', isRelationship: false }, ], actions: [], }, @@ -63,14 +63,14 @@ describe('AgentClientAgentPort', () => { collectionDisplayName: 'Posts', primaryKeyFields: ['id'], fields: [ - { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, - { fieldName: 'title', displayName: 'title', type: 'String', isRelationship: false }, + { fieldName: 'id', displayName: 'id', isRelationship: false }, + { fieldName: 'title', displayName: 'title', isRelationship: false }, ], actions: [], }, }; - port = new AgentClientAgentPort({ client, collectionRefs }); + port = new AgentClientAgentPort({ client, collectionSchemas }); }); describe('getRecord', () => { @@ -84,12 +84,8 @@ describe('AgentClientAgentPort', () => { pagination: { size: 1, number: 1 }, }); expect(result).toEqual({ - recordId: [42], collectionName: 'users', - collectionDisplayName: 'Users', - primaryKeyFields: ['id'], - fields: collectionRefs.users.fields, - actions: collectionRefs.users.actions, + recordId: [42], values: { id: 42, name: 'Alice' }, }); }); @@ -128,7 +124,6 @@ describe('AgentClientAgentPort', () => { }), ); expect(result.collectionName).toBe('unknown'); - expect(result.fields).toEqual([]); }); }); @@ -140,12 +135,8 @@ describe('AgentClientAgentPort', () => { expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); expect(result).toEqual({ - recordId: [42], collectionName: 'users', - collectionDisplayName: 'Users', - primaryKeyFields: ['id'], - fields: collectionRefs.users.fields, - actions: collectionRefs.users.actions, + recordId: [42], values: { id: 42, name: 'Bob' }, }); }); @@ -171,27 +162,19 @@ describe('AgentClientAgentPort', () => { expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); expect(result).toEqual([ { - recordId: [10], collectionName: 'posts', - collectionDisplayName: 'Posts', - primaryKeyFields: ['id'], - fields: collectionRefs.posts.fields, - actions: collectionRefs.posts.actions, + recordId: [10], values: { id: 10, title: 'Post A' }, }, { - recordId: [11], collectionName: 'posts', - collectionDisplayName: 'Posts', - primaryKeyFields: ['id'], - fields: collectionRefs.posts.fields, - actions: collectionRefs.posts.actions, + recordId: [11], values: { id: 11, title: 'Post B' }, }, ]); }); - it('should fallback to relationName when no CollectionRef exists', async () => { + it('should fallback to relationName when no CollectionSchema exists', async () => { mockRelation.list.mockResolvedValue([{ id: 1 }]); const result = await port.getRelatedData('users', [42], 'unknownRelation'); @@ -208,7 +191,7 @@ describe('AgentClientAgentPort', () => { }); describe('getActions', () => { - it('should return ActionRef[] from CollectionRef', async () => { + it('should return ActionRef[] from CollectionSchema', async () => { expect(await port.getActions('users')).toEqual([ { name: 'sendEmail', displayName: 'Send Email' }, { name: 'archive', displayName: 'Archive' }, 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 9d4f74a98..66b0de964 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 @@ -1,6 +1,7 @@ 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 { RecordData } from '../../src/types/record'; +import type { CollectionSchema, RecordData } from '../../src/types/record'; import type { AiTaskStepDefinition } from '../../src/types/step-definition'; import type { AiTaskStepHistory } from '../../src/types/step-history'; @@ -28,21 +29,9 @@ function makeStepHistory(overrides: Partial = {}): AiTaskStep function makeRecord(overrides: Partial = {}): RecordData { return { - stepIndex: 0, - recordId: '42', collectionName: 'customers', - collectionDisplayName: 'Customers', - fields: [ - { fieldName: 'email', displayName: 'Email', type: 'String', isRelationship: false }, - { fieldName: 'name', displayName: 'Full Name', type: 'String', isRelationship: false }, - { - fieldName: 'orders', - displayName: 'Orders', - type: 'HasMany', - isRelationship: true, - referencedCollectionName: 'orders', - }, - ], + recordId: [42], + stepIndex: 0, values: { email: 'john@example.com', name: 'John Doe', @@ -52,6 +41,21 @@ function makeRecord(overrides: Partial = {}): RecordData { }; } +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'name', displayName: 'Full Name', isRelationship: false }, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true }, + ], + actions: [], + ...overrides, + }; +} + function makeMockRunStore(overrides: Partial = {}): RunStore { return { getRecords: jest.fn().mockResolvedValue([makeRecord()]), @@ -64,6 +68,25 @@ function makeMockRunStore(overrides: Partial = {}): RunStore { }; } +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + completeStepExecution: 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 = 'read-selected-record-fields', @@ -82,7 +105,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex runId: 'run-1', model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: {} as ExecutionContext['agentPort'], - workflowPort: {} as ExecutionContext['workflowPort'], + workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), history: [], remoteTools: [], @@ -213,22 +236,16 @@ describe('ReadRecordStepExecutor', () => { describe('no readable fields', () => { it('returns error when all fields are relationships', async () => { - const record = makeRecord({ - fields: [ - { - fieldName: 'orders', - displayName: 'Orders', - type: 'HasMany', - isRelationship: true, - referencedCollectionName: 'orders', - }, - ], + const record = makeRecord({ collectionName: 'customers' }); + const schema = makeCollectionSchema({ + fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], }); const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record]), }); - const context = makeContext({ model: mockModel.model, runStore }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -246,15 +263,17 @@ describe('ReadRecordStepExecutor', () => { const record1 = makeRecord({ stepIndex: 1 }); const record2 = makeRecord({ stepIndex: 2, - recordId: '99', + recordId: [99], collectionName: 'orders', - collectionDisplayName: 'Orders', - fields: [ - { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, - ], values: { total: 150 }, }); + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'total', displayName: 'Total', isRelationship: false }], + }); + // First call: select-record, second call: read-selected-record-fields const invoke = jest .fn() @@ -282,7 +301,11 @@ describe('ReadRecordStepExecutor', () => { const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record1, record2]), }); - const context = makeContext({ model, runStore }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -309,8 +332,8 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], }, - selectedRecordRef: expect.objectContaining({ - recordId: '42', + selectedRecord: expect.objectContaining({ + recordId: [42], collectionName: 'customers', }), }), @@ -321,15 +344,17 @@ describe('ReadRecordStepExecutor', () => { const record1 = makeRecord({ stepIndex: 1 }); const record2 = makeRecord({ stepIndex: 2, - recordId: '99', + recordId: [99], collectionName: 'orders', - collectionDisplayName: 'Orders', - fields: [ - { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, - ], values: { total: 150 }, }); + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'total', displayName: 'Total', isRelationship: false }], + }); + const invoke = jest .fn() .mockResolvedValueOnce({ @@ -352,7 +377,11 @@ describe('ReadRecordStepExecutor', () => { const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record1, record2]), }); - const context = makeContext({ model, runStore }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -363,8 +392,8 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [{ value: 150, fieldName: 'total', displayName: 'Total' }], }, - selectedRecordRef: expect.objectContaining({ - recordId: '99', + selectedRecord: expect.objectContaining({ + recordId: [99], collectionName: 'orders', }), }), @@ -375,15 +404,17 @@ describe('ReadRecordStepExecutor', () => { const record1 = makeRecord({ stepIndex: 3 }); const record2 = makeRecord({ stepIndex: 5, - recordId: '99', + recordId: [99], collectionName: 'orders', - collectionDisplayName: 'Orders', - fields: [ - { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, - ], values: { total: 150 }, }); + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'total', displayName: 'Total', isRelationship: false }], + }); + const invoke = jest .fn() .mockResolvedValueOnce({ @@ -406,7 +437,11 @@ describe('ReadRecordStepExecutor', () => { const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record1, record2]), }); - const executor = new ReadRecordStepExecutor(makeContext({ model, runStore })); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const executor = new ReadRecordStepExecutor(makeContext({ model, runStore, workflowPort })); await executor.execute(makeStep(), makeStepHistory()); @@ -424,15 +459,17 @@ describe('ReadRecordStepExecutor', () => { it('returns error when AI selects a non-existent record identifier', async () => { const record1 = makeRecord(); const record2 = makeRecord({ - recordId: '99', + recordId: [99], collectionName: 'orders', - collectionDisplayName: 'Orders', - fields: [ - { fieldName: 'total', displayName: 'Total', type: 'Number', isRelationship: false }, - ], values: { total: 150 }, }); + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'total', displayName: 'Total', isRelationship: false }], + }); + const invoke = jest.fn().mockResolvedValueOnce({ tool_calls: [ { name: 'select-record', args: { recordIdentifier: 'NonExistent #999' }, id: 'call_1' }, @@ -444,7 +481,11 @@ describe('ReadRecordStepExecutor', () => { const runStore = makeMockRunStore({ getRecords: jest.fn().mockResolvedValue([record1, record2]), }); - const context = makeContext({ model, runStore }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -639,7 +680,7 @@ describe('ReadRecordStepExecutor', () => { }); describe('saveStepExecution arguments', () => { - it('saves executionParams, executionResult, and selectedRecordRef', async () => { + it('saves executionParams, executionResult, and selectedRecord', async () => { const record = makeRecord(); const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore({ @@ -660,11 +701,10 @@ describe('ReadRecordStepExecutor', () => { { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, ], }, - selectedRecordRef: { - recordId: '42', + selectedRecord: { collectionName: 'customers', - collectionDisplayName: 'Customers', - fields: record.fields, + recordId: [42], + stepIndex: 0, }, }); }); From cd98937a482f2271d0ff965d944093662aba3863 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 16:53:25 +0100 Subject: [PATCH 15/37] refactor(workflow-executor): rename ActionRef to ActionSchema and remove AgentPort.getActions Actions metadata is structural info belonging to CollectionSchema, not an AgentPort concern. Remove the redundant getActions method from AgentPort and rename ActionRef to ActionSchema for naming consistency. Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/agent-client-agent-port.ts | 8 +------- packages/workflow-executor/src/index.ts | 2 +- packages/workflow-executor/src/ports/agent-port.ts | 3 +-- packages/workflow-executor/src/types/record.ts | 4 ++-- .../test/adapters/agent-client-agent-port.test.ts | 13 ------------- 5 files changed, 5 insertions(+), 25 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 50b8f4596..5f9382c5e 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,5 +1,5 @@ import type { AgentPort } from '../ports/agent-port'; -import type { ActionRef, CollectionSchema } from '../types/record'; +import type { CollectionSchema } from '../types/record'; import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; @@ -91,12 +91,6 @@ export default class AgentClientAgentPort implements AgentPort { })); } - async getActions(collectionName: string): Promise { - const schema = this.collectionSchemas[collectionName]; - - return schema ? schema.actions : []; - } - async executeAction( collectionName: string, actionName: string, diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 314cf82f4..70429b5e8 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -25,7 +25,7 @@ export type { export type { FieldSchema, - ActionRef, + ActionSchema, CollectionSchema, RecordRef, RecordData, diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 1f4d949e2..3e65475ea 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { ActionRef, RecordData } from '../types/record'; +import type { RecordData } from '../types/record'; type AgentRecord = Omit; @@ -16,7 +16,6 @@ export interface AgentPort { recordId: Array, relationName: string, ): Promise; - getActions(collectionName: string): Promise; executeAction( collectionName: string, actionName: string, diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index dd95498aa..3f8b22511 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -8,7 +8,7 @@ export interface FieldSchema { isRelationship: boolean; } -export interface ActionRef { +export interface ActionSchema { name: string; displayName: string; } @@ -18,7 +18,7 @@ export interface CollectionSchema { collectionDisplayName: string; primaryKeyFields: string[]; fields: FieldSchema[]; - actions: ActionRef[]; + actions: ActionSchema[]; } // -- Record types (data — source: AgentPort/RunStore) -- 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 047e09200..e39cb8a6f 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 @@ -190,19 +190,6 @@ describe('AgentClientAgentPort', () => { }); }); - describe('getActions', () => { - it('should return ActionRef[] from CollectionSchema', async () => { - expect(await port.getActions('users')).toEqual([ - { name: 'sendEmail', displayName: 'Send Email' }, - { name: 'archive', displayName: 'Archive' }, - ]); - }); - - it('should return an empty array for an unknown collection', async () => { - expect(await port.getActions('unknown')).toEqual([]); - }); - }); - describe('executeAction', () => { it('should encode recordIds to pipe format and call execute', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); From 49b05bfdefa09a5b8f59f9ebc422f0bc8e52e5ce Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:04:46 +0100 Subject: [PATCH 16/37] refactor(workflow-executor): remove dead RunStore methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove getRecord, saveRecord, and getStepExecution from RunStore interface — none are called in production code. Records are accessed via getRecords, step executions via getStepExecutions/saveStepExecution. Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/src/ports/run-store.ts | 3 --- .../test/executors/base-step-executor.test.ts | 3 --- .../test/executors/condition-step-executor.test.ts | 4 ---- .../test/executors/read-record-step-executor.test.ts | 3 --- 4 files changed, 13 deletions(-) diff --git a/packages/workflow-executor/src/ports/run-store.ts b/packages/workflow-executor/src/ports/run-store.ts index 212ab1408..ce638dd20 100644 --- a/packages/workflow-executor/src/ports/run-store.ts +++ b/packages/workflow-executor/src/ports/run-store.ts @@ -5,9 +5,6 @@ import type { StepExecutionData } from '../types/step-execution-data'; export interface RunStore { getRecords(): Promise; - getRecord(collectionName: string, recordId: string): Promise; - saveRecord(record: RecordData): Promise; getStepExecutions(): Promise; - getStepExecution(stepIndex: number): Promise; saveStepExecution(stepExecution: StepExecutionData): Promise; } 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 de5d7d572..b8b8f4389 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -50,10 +50,7 @@ function makeHistoryEntry( function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { return { getRecords: jest.fn().mockResolvedValue([]), - getRecord: jest.fn().mockResolvedValue(null), - saveRecord: jest.fn().mockResolvedValue(undefined), getStepExecutions: jest.fn().mockResolvedValue(stepExecutions), - getStepExecution: jest.fn().mockResolvedValue(null), saveStepExecution: jest.fn().mockResolvedValue(undefined), }; } 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 ba7fe7f34..de50d69d3 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -29,10 +29,7 @@ function makeStepHistory(overrides: Partial = {}): Conditi function makeMockRunStore(overrides: Partial = {}): RunStore { return { getRecords: jest.fn().mockResolvedValue([]), - getRecord: jest.fn().mockResolvedValue(null), - saveRecord: jest.fn().mockResolvedValue(undefined), getStepExecutions: jest.fn().mockResolvedValue([]), - getStepExecution: jest.fn().mockResolvedValue(null), saveStepExecution: jest.fn().mockResolvedValue(undefined), ...overrides, }; @@ -181,7 +178,6 @@ describe('ConditionStepExecutor', () => { question: 'Final approval?', }); const runStore = makeMockRunStore({ - getStepExecution: jest.fn().mockResolvedValue(null), getStepExecutions: jest.fn().mockResolvedValue([ { type: '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 66b0de964..d277d600e 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 @@ -59,10 +59,7 @@ function makeCollectionSchema(overrides: Partial = {}): Collec function makeMockRunStore(overrides: Partial = {}): RunStore { return { getRecords: jest.fn().mockResolvedValue([makeRecord()]), - getRecord: jest.fn().mockResolvedValue(null), - saveRecord: jest.fn().mockResolvedValue(undefined), getStepExecutions: jest.fn().mockResolvedValue([]), - getStepExecution: jest.fn().mockResolvedValue(null), saveStepExecution: jest.fn().mockResolvedValue(undefined), ...overrides, }; From 5de89472b04663480643f9d12be7c958039f335c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:28:46 +0100 Subject: [PATCH 17/37] refactor(workflow-executor): replace RunStore.getRecords with baseRecord + LoadRelatedRecordStepExecutionData Records are now accessed via ExecutionContext.baseRecord (trigger record) and LoadRelatedRecordStepExecutionData entries in the RunStore, removing the separate getRecords() method. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 2 +- .../executors/read-record-step-executor.ts | 16 +++- packages/workflow-executor/src/index.ts | 1 + .../workflow-executor/src/ports/run-store.ts | 2 - .../workflow-executor/src/types/execution.ts | 2 + .../src/types/step-execution-data.ts | 12 ++- .../test/executors/base-step-executor.test.ts | 8 +- .../executors/condition-step-executor.test.ts | 8 +- .../read-record-step-executor.test.ts | 84 +++++++++---------- 9 files changed, 82 insertions(+), 53 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 6c4fc7694..695f94299 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -60,7 +60,7 @@ export default abstract class BaseStepExecutor< const header = `Step "${step.id}" (index ${stepHistory.stepIndex}):`; const lines = [header, ` Prompt: ${prompt}`]; - if (execution?.executionParams) { + if (execution && 'executionParams' in execution) { lines.push(` Result: ${JSON.stringify(execution.executionParams)}`); } else { const { stepId, stepIndex, type, ...historyDetails } = stepHistory; 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 3f981a16a..10639f2b9 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,7 +1,10 @@ import type { StepExecutionResult } from '../types/execution'; import type { CollectionSchema, RecordData } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; -import type { FieldReadResult } from '../types/step-execution-data'; +import type { + FieldReadResult, + LoadRelatedRecordStepExecutionData, +} from '../types/step-execution-data'; import type { AiTaskStepHistory } from '../types/step-history'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; @@ -27,7 +30,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< step: AiTaskStepDefinition, stepHistory: AiTaskStepHistory, ): Promise { - const records = await this.context.runStore.getRecords(); + const records = await this.getAvailableRecords(); let selectedRecord: RecordData; let schema: CollectionSchema; @@ -168,6 +171,15 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } + private async getAvailableRecords(): Promise { + 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.baseRecord, ...relatedRecords]; + } + private async toRecordIdentifier(record: RecordData): Promise { const schema = await this.context.workflowPort.getCollectionSchema(record.collectionName); diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 70429b5e8..dec5dd90b 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -20,6 +20,7 @@ export type { ConditionStepExecutionData, ReadRecordStepExecutionData, AiTaskStepExecutionData, + LoadRelatedRecordStepExecutionData, StepExecutionData, } from './types/step-execution-data'; diff --git a/packages/workflow-executor/src/ports/run-store.ts b/packages/workflow-executor/src/ports/run-store.ts index ce638dd20..6b899da84 100644 --- a/packages/workflow-executor/src/ports/run-store.ts +++ b/packages/workflow-executor/src/ports/run-store.ts @@ -1,10 +1,8 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordData } from '../types/record'; import type { StepExecutionData } from '../types/step-execution-data'; export interface RunStore { - getRecords(): Promise; getStepExecutions(): Promise; saveStepExecution(stepExecution: StepExecutionData): Promise; } diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index dd189c6ed..7bbf60d19 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -1,5 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ +import type { RecordData } from './record'; import type { StepDefinition } from './step-definition'; import type { StepHistory } from './step-history'; import type { AgentPort } from '../ports/agent-port'; @@ -28,6 +29,7 @@ export interface StepExecutionResult { export interface ExecutionContext { readonly runId: string; + readonly baseRecord: RecordData; readonly model: BaseChatModel; readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index e690dfcb8..35e80a65d 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordRef } from './record'; +import type { RecordData, RecordRef } from './record'; // -- Base -- @@ -49,9 +49,17 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { toolConfirmationInterruption?: Record; } +// -- Load Related Record -- + +export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { + type: 'load-related-record'; + record: RecordData; +} + // -- Union -- export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData - | AiTaskStepExecutionData; + | AiTaskStepExecutionData + | LoadRelatedRecordStepExecutionData; 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 b8b8f4389..2afc743d5 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -1,5 +1,6 @@ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; +import type { RecordData } from '../../src/types/record'; import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; import type { StepHistory } from '../../src/types/step-history'; @@ -49,7 +50,6 @@ function makeHistoryEntry( function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { return { - getRecords: jest.fn().mockResolvedValue([]), getStepExecutions: jest.fn().mockResolvedValue(stepExecutions), saveStepExecution: jest.fn().mockResolvedValue(undefined), }; @@ -58,6 +58,12 @@ function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', + baseRecord: { + collectionName: 'customers', + recordId: [1], + stepIndex: 0, + values: {}, + } as RecordData, model: {} as ExecutionContext['model'], agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], 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 de50d69d3..5b67c50d3 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -1,5 +1,6 @@ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext } from '../../src/types/execution'; +import type { RecordData } from '../../src/types/record'; import type { ConditionStepDefinition } from '../../src/types/step-definition'; import type { ConditionStepHistory } from '../../src/types/step-history'; @@ -28,7 +29,6 @@ function makeStepHistory(overrides: Partial = {}): Conditi function makeMockRunStore(overrides: Partial = {}): RunStore { return { - getRecords: jest.fn().mockResolvedValue([]), getStepExecutions: jest.fn().mockResolvedValue([]), saveStepExecution: jest.fn().mockResolvedValue(undefined), ...overrides, @@ -50,6 +50,12 @@ function makeMockModel(toolCallArgs?: Record) { function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', + baseRecord: { + collectionName: 'customers', + recordId: [1], + stepIndex: 0, + values: {}, + } as RecordData, model: makeMockModel().model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], 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 d277d600e..1ca6f78fe 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 @@ -58,7 +58,6 @@ function makeCollectionSchema(overrides: Partial = {}): Collec function makeMockRunStore(overrides: Partial = {}): RunStore { return { - getRecords: jest.fn().mockResolvedValue([makeRecord()]), getStepExecutions: jest.fn().mockResolvedValue([]), saveStepExecution: jest.fn().mockResolvedValue(undefined), ...overrides, @@ -100,6 +99,7 @@ function makeMockModel( function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', + baseRecord: makeRecord(), model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: makeMockWorkflowPort(), @@ -233,14 +233,11 @@ describe('ReadRecordStepExecutor', () => { describe('no readable fields', () => { it('returns error when all fields are relationships', async () => { - const record = makeRecord({ collectionName: 'customers' }); const schema = makeCollectionSchema({ fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], }); const mockModel = makeMockModel({ fieldNames: ['email'] }); - const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record]), - }); + const runStore = makeMockRunStore(); const workflowPort = makeMockWorkflowPort({ customers: schema }); const context = makeContext({ model: mockModel.model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); @@ -257,8 +254,8 @@ describe('ReadRecordStepExecutor', () => { describe('multi-record AI selection', () => { it('uses AI to select among multiple records then reads fields', async () => { - const record1 = makeRecord({ stepIndex: 1 }); - const record2 = makeRecord({ + const baseRecord = makeRecord({ stepIndex: 1 }); + const relatedRecord = makeRecord({ stepIndex: 2, recordId: [99], collectionName: 'orders', @@ -296,13 +293,17 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record1, record2]), + getStepExecutions: jest + .fn() + .mockResolvedValue([ + { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ model, runStore, workflowPort }); + const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -338,8 +339,8 @@ describe('ReadRecordStepExecutor', () => { }); it('reads fields from the second record when AI selects it', async () => { - const record1 = makeRecord({ stepIndex: 1 }); - const record2 = makeRecord({ + const baseRecord = makeRecord({ stepIndex: 1 }); + const relatedRecord = makeRecord({ stepIndex: 2, recordId: [99], collectionName: 'orders', @@ -372,13 +373,17 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record1, record2]), + getStepExecutions: jest + .fn() + .mockResolvedValue([ + { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ model, runStore, workflowPort }); + const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -398,8 +403,8 @@ describe('ReadRecordStepExecutor', () => { }); it('includes step index in select-record tool schema when records have stepIndex', async () => { - const record1 = makeRecord({ stepIndex: 3 }); - const record2 = makeRecord({ + const baseRecord = makeRecord({ stepIndex: 3 }); + const relatedRecord = makeRecord({ stepIndex: 5, recordId: [99], collectionName: 'orders', @@ -432,13 +437,19 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record1, record2]), + getStepExecutions: jest + .fn() + .mockResolvedValue([ + { type: 'load-related-record', stepIndex: 5, record: relatedRecord }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), orders: ordersSchema, }); - const executor = new ReadRecordStepExecutor(makeContext({ model, runStore, workflowPort })); + const executor = new ReadRecordStepExecutor( + makeContext({ baseRecord, model, runStore, workflowPort }), + ); await executor.execute(makeStep(), makeStepHistory()); @@ -454,8 +465,9 @@ describe('ReadRecordStepExecutor', () => { describe('AI record selection failure', () => { it('returns error when AI selects a non-existent record identifier', async () => { - const record1 = makeRecord(); - const record2 = makeRecord({ + const baseRecord = makeRecord(); + const relatedRecord = makeRecord({ + stepIndex: 1, recordId: [99], collectionName: 'orders', values: { total: 150 }, @@ -476,13 +488,17 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record1, record2]), + getStepExecutions: jest + .fn() + .mockResolvedValue([ + { type: 'load-related-record', stepIndex: 1, record: relatedRecord }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ model, runStore, workflowPort }); + const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -495,23 +511,6 @@ describe('ReadRecordStepExecutor', () => { }); }); - describe('no records available', () => { - it('returns error when no records exist', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); - const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([]), - }); - const context = makeContext({ model: mockModel.model, runStore }); - const executor = new ReadRecordStepExecutor(context); - - const result = await executor.execute(makeStep(), makeStepHistory()); - - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe('No records available'); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); - }); - }); - describe('model error', () => { it('returns error status when AI invocation fails', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); @@ -586,10 +585,10 @@ describe('ReadRecordStepExecutor', () => { await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('Storage full'); }); - it('lets getRecords errors propagate', async () => { + it('lets getStepExecutions errors propagate', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ - getRecords: jest.fn().mockRejectedValue(new Error('Connection lost')), + getStepExecutions: jest.fn().mockRejectedValue(new Error('Connection lost')), }); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); @@ -678,11 +677,8 @@ describe('ReadRecordStepExecutor', () => { describe('saveStepExecution arguments', () => { it('saves executionParams, executionResult, and selectedRecord', async () => { - const record = makeRecord(); const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); - const runStore = makeMockRunStore({ - getRecords: jest.fn().mockResolvedValue([record]), - }); + const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); From c1d2ce03ec184947184c2c5b1068a9115c5b586c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:40:16 +0100 Subject: [PATCH 18/37] feat(workflow-executor): include executionResult in buildStepSummary Add isExecutedStepOnExecutor type guard and ExecutedStepExecutionData union type. buildStepSummary now outputs executionResult so subsequent steps can access field values read by earlier steps. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 7 ++++++- packages/workflow-executor/src/index.ts | 3 +++ .../src/types/step-execution-data.ts | 11 +++++++++++ .../test/executors/base-step-executor.test.ts | 4 ++++ 4 files changed, 24 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 695f94299..bb0921497 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -8,6 +8,7 @@ import type { DynamicStructuredTool } from '@langchain/core/tools'; import { SystemMessage } from '@langchain/core/messages'; import { MalformedToolCallError, MissingToolCallError } from '../errors'; +import { isExecutedStepOnExecutor } from '../types/step-execution-data'; export default abstract class BaseStepExecutor< TStep extends StepDefinition = StepDefinition, @@ -60,8 +61,12 @@ export default abstract class BaseStepExecutor< const header = `Step "${step.id}" (index ${stepHistory.stepIndex}):`; const lines = [header, ` Prompt: ${prompt}`]; - if (execution && 'executionParams' in execution) { + if (isExecutedStepOnExecutor(execution)) { lines.push(` Result: ${JSON.stringify(execution.executionParams)}`); + + if (execution.executionResult) { + lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); + } } else { const { stepId, stepIndex, type, ...historyDetails } = stepHistory; lines.push(` History: ${JSON.stringify(historyDetails)}`); diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index dec5dd90b..22a612b6a 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -21,9 +21,12 @@ export type { ReadRecordStepExecutionData, AiTaskStepExecutionData, LoadRelatedRecordStepExecutionData, + ExecutedStepExecutionData, StepExecutionData, } from './types/step-execution-data'; +export { isExecutedStepOnExecutor } from './types/step-execution-data'; + export type { FieldSchema, ActionSchema, diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 35e80a65d..b0aca2b4a 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -63,3 +63,14 @@ export type StepExecutionData = | ReadRecordStepExecutionData | AiTaskStepExecutionData | LoadRelatedRecordStepExecutionData; + +export type ExecutedStepExecutionData = + | ConditionStepExecutionData + | ReadRecordStepExecutionData + | AiTaskStepExecutionData; + +export function isExecutedStepOnExecutor( + data: StepExecutionData | undefined, +): data is ExecutedStepExecutionData { + return !!data && data.type !== 'load-related-record'; +} 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 2afc743d5..429a79537 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -104,6 +104,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Step "cond-1"'); expect(result).toContain('Prompt: Approve?'); expect(result).toContain('Result: {"answer":"Yes","reasoning":"Order is valid"}'); + expect(result).toContain('Output: {"answer":"Yes"}'); }); it('uses Result for matched steps and History for unmatched steps', async () => { @@ -133,6 +134,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('History: {"status":"success"}'); expect(result).toContain('Step "cond-2"'); expect(result).toContain('Result: {"answer":"No","reasoning":"Clearly no"}'); + expect(result).toContain('Output: {"answer":"No"}'); }); it('falls back to History when no matching step execution in RunStore', async () => { @@ -161,6 +163,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('History: {"status":"success"}'); expect(result).toContain('Step "matched"'); expect(result).toContain('Result: {"answer":"B","reasoning":"Option B fits"}'); + expect(result).toContain('Output: {"answer":"B"}'); }); it('includes selectedOption in History for condition steps', async () => { @@ -290,6 +293,7 @@ describe('BaseStepExecutor', () => { .then(msgs => msgs[0]?.content ?? ''); expect(result).toContain('Result: {"answer":"A","reasoning":"Best fit"}'); + expect(result).toContain('Output: {"answer":"A"}'); expect(result).not.toContain('History:'); }); From 0de5c49e840fb963c1c04e0ea09c7462469f700e Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:43:15 +0100 Subject: [PATCH 19/37] refactor(workflow-executor): rename Result to Input in buildStepSummary executionParams is the input (AI decision), executionResult is the output. Rename the summary label accordingly for clarity. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 6 +++--- .../test/executors/base-step-executor.test.ts | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index bb0921497..a6eedd163 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -36,9 +36,9 @@ export default abstract class BaseStepExecutor< /** * Builds a text summary of previously executed steps for AI prompts. - * Correlates history entries (step + stepHistory pairs) with executionParams + * Correlates history entries (step + stepHistory pairs) with execution data * from the RunStore (matched by stepHistory.stepIndex). - * When no executionParams is available, falls back to StepHistory details. + * When no execution data is available, falls back to StepHistory details. */ private async summarizePreviousSteps(): Promise { const allStepExecutions = await this.context.runStore.getStepExecutions(); @@ -62,7 +62,7 @@ export default abstract class BaseStepExecutor< const lines = [header, ` Prompt: ${prompt}`]; if (isExecutedStepOnExecutor(execution)) { - lines.push(` Result: ${JSON.stringify(execution.executionParams)}`); + lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); if (execution.executionResult) { lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); 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 429a79537..007254503 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -103,11 +103,11 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Step "cond-1"'); expect(result).toContain('Prompt: Approve?'); - expect(result).toContain('Result: {"answer":"Yes","reasoning":"Order is valid"}'); + expect(result).toContain('Input: {"answer":"Yes","reasoning":"Order is valid"}'); expect(result).toContain('Output: {"answer":"Yes"}'); }); - it('uses Result for matched steps and History for unmatched steps', async () => { + it('uses Input for matched steps and History for unmatched steps', async () => { const executor = new TestableExecutor( makeContext({ history: [ @@ -133,7 +133,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Step "cond-1"'); expect(result).toContain('History: {"status":"success"}'); expect(result).toContain('Step "cond-2"'); - expect(result).toContain('Result: {"answer":"No","reasoning":"Clearly no"}'); + expect(result).toContain('Input: {"answer":"No","reasoning":"Clearly no"}'); expect(result).toContain('Output: {"answer":"No"}'); }); @@ -162,7 +162,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Step "orphan"'); expect(result).toContain('History: {"status":"success"}'); expect(result).toContain('Step "matched"'); - expect(result).toContain('Result: {"answer":"B","reasoning":"Option B fits"}'); + expect(result).toContain('Input: {"answer":"B","reasoning":"Option B fits"}'); expect(result).toContain('Output: {"answer":"B"}'); }); @@ -234,7 +234,7 @@ describe('BaseStepExecutor', () => { expect(result).toContain('History: {"status":"awaiting-input"}'); }); - it('uses Result when RunStore has executionParams, History otherwise', async () => { + it('uses Input when RunStore has executionParams, History otherwise', async () => { const condEntry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, @@ -267,10 +267,10 @@ describe('BaseStepExecutor', () => { expect(result).toContain('Step "cond-1"'); expect(result).toContain('History: {"status":"success","selectedOption":"Yes"}'); expect(result).toContain('Step "read-customer"'); - expect(result).toContain('Result: {"answer":"John Doe"}'); + expect(result).toContain('Input: {"answer":"John Doe"}'); }); - it('prefers RunStore executionParams over History fallback', async () => { + it('prefers RunStore execution data over History fallback', async () => { const entry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Pick one' }); (entry.stepHistory as { selectedOption?: string }).selectedOption = 'A'; @@ -292,7 +292,7 @@ describe('BaseStepExecutor', () => { .buildPreviousStepsMessages() .then(msgs => msgs[0]?.content ?? ''); - expect(result).toContain('Result: {"answer":"A","reasoning":"Best fit"}'); + expect(result).toContain('Input: {"answer":"A","reasoning":"Best fit"}'); expect(result).toContain('Output: {"answer":"A"}'); expect(result).not.toContain('History:'); }); From 45e8909cfc0cde33062a616d0e72fbbedf95bdfb Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:45:30 +0100 Subject: [PATCH 20/37] refactor(workflow-executor): rename selectedRecord to selectedRecordRef The field holds a RecordRef, not a RecordData. Rename for clarity. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 2 +- packages/workflow-executor/src/types/step-execution-data.ts | 2 +- .../test/executors/read-record-step-executor.test.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 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 10639f2b9..499538112 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -51,7 +51,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< stepIndex: stepHistory.stepIndex, executionParams: { fieldNames }, executionResult: { fields: fieldResults }, - selectedRecord: { + selectedRecordRef: { collectionName: selectedRecord.collectionName, recordId: selectedRecord.recordId, stepIndex: selectedRecord.stepIndex, diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index b0aca2b4a..21376a78b 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 interface ReadRecordStepExecutionData extends BaseStepExecutionData { type: 'read-record'; executionParams: { fieldNames: string[] }; executionResult: { fields: FieldReadResult[] }; - selectedRecord: RecordRef; + selectedRecordRef: RecordRef; } // -- Generic AI Task (fallback for untyped steps) -- 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 1ca6f78fe..11faca09e 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 @@ -330,7 +330,7 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], }, - selectedRecord: expect.objectContaining({ + selectedRecordRef: expect.objectContaining({ recordId: [42], collectionName: 'customers', }), @@ -394,7 +394,7 @@ describe('ReadRecordStepExecutor', () => { executionResult: { fields: [{ value: 150, fieldName: 'total', displayName: 'Total' }], }, - selectedRecord: expect.objectContaining({ + selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders', }), @@ -694,7 +694,7 @@ describe('ReadRecordStepExecutor', () => { { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, ], }, - selectedRecord: { + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0, From 3a12b0131e7ab813f5e1fae3c938d6b88f6cffc4 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 17:47:19 +0100 Subject: [PATCH 21/37] chore(workflow-executor): add TODO on isExecutedStepOnExecutor Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/src/types/step-execution-data.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 21376a78b..1f2fe6c45 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -69,6 +69,8 @@ export type ExecutedStepExecutionData = | ReadRecordStepExecutionData | AiTaskStepExecutionData; +// 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 { From 2a71ceec6666649a5ff4707ae78bab55310bd348 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 18:06:48 +0100 Subject: [PATCH 22/37] feat(workflow-executor): change baseRecord to RecordRef and fetch values via AgentPort The orchestrator only sends a record reference (collection + recordId), not client data. ReadRecordStepExecutor now fetches values through agentPort.getRecord() when needed. Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 31 ++++---- .../workflow-executor/src/types/execution.ts | 4 +- .../test/executors/base-step-executor.test.ts | 5 +- .../executors/condition-step-executor.test.ts | 5 +- .../read-record-step-executor.test.ts | 70 ++++++++++++++++--- 5 files changed, 84 insertions(+), 31 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 499538112..902ac03d1 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, RecordData } from '../types/record'; +import type { CollectionSchema, RecordRef } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult, @@ -32,19 +32,25 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< ): Promise { const records = await this.getAvailableRecords(); - let selectedRecord: RecordData; + let selectedRef: RecordRef; let schema: CollectionSchema; let fieldNames: string[]; + let values: Record; try { - selectedRecord = await this.selectRecord(records, step.prompt); - schema = await this.context.workflowPort.getCollectionSchema(selectedRecord.collectionName); + selectedRef = await this.selectRecord(records, step.prompt); + schema = await this.context.workflowPort.getCollectionSchema(selectedRef.collectionName); fieldNames = await this.selectFields(schema, step.prompt); + const agentRecord = await this.context.agentPort.getRecord( + selectedRef.collectionName, + selectedRef.recordId, + ); + values = agentRecord.values; } catch (error) { return { stepHistory: { ...stepHistory, status: 'error', error: (error as Error).message } }; } - const fieldResults = this.readFieldValues(selectedRecord.values, schema, fieldNames); + const fieldResults = this.readFieldValues(values, schema, fieldNames); await this.context.runStore.saveStepExecution({ type: 'read-record', @@ -52,9 +58,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< executionParams: { fieldNames }, executionResult: { fields: fieldResults }, selectedRecordRef: { - collectionName: selectedRecord.collectionName, - recordId: selectedRecord.recordId, - stepIndex: selectedRecord.stepIndex, + collectionName: selectedRef.collectionName, + recordId: selectedRef.recordId, + stepIndex: selectedRef.stepIndex, }, }); @@ -80,10 +86,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return args.fieldNames; } - private async selectRecord( - records: RecordData[], - prompt: string | undefined, - ): Promise { + private async selectRecord(records: RecordRef[], prompt: string | undefined): Promise { if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; @@ -171,7 +174,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private async getAvailableRecords(): Promise { + private async getAvailableRecords(): Promise { const stepExecutions = await this.context.runStore.getStepExecutions(); const relatedRecords = stepExecutions .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') @@ -180,7 +183,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return [this.context.baseRecord, ...relatedRecords]; } - private async toRecordIdentifier(record: RecordData): Promise { + private async toRecordIdentifier(record: RecordRef): Promise { const schema = await this.context.workflowPort.getCollectionSchema(record.collectionName); return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 7bbf60d19..f9124f0fc 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordData } from './record'; +import type { RecordRef } from './record'; import type { StepDefinition } from './step-definition'; import type { StepHistory } from './step-history'; import type { AgentPort } from '../ports/agent-port'; @@ -29,7 +29,7 @@ export interface StepExecutionResult { export interface ExecutionContext { readonly runId: string; - readonly baseRecord: RecordData; + readonly baseRecord: RecordRef; readonly model: BaseChatModel; readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; 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 007254503..2fa422503 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -1,6 +1,6 @@ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; -import type { RecordData } from '../../src/types/record'; +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 { StepHistory } from '../../src/types/step-history'; @@ -62,8 +62,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex collectionName: 'customers', recordId: [1], stepIndex: 0, - values: {}, - } as RecordData, + } as RecordRef, model: {} as ExecutionContext['model'], agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], 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 5b67c50d3..6b5b82fdb 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -1,6 +1,6 @@ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext } from '../../src/types/execution'; -import type { RecordData } from '../../src/types/record'; +import type { RecordRef } from '../../src/types/record'; import type { ConditionStepDefinition } from '../../src/types/step-definition'; import type { ConditionStepHistory } from '../../src/types/step-history'; @@ -54,8 +54,7 @@ function makeContext(overrides: Partial = {}): ExecutionContex collectionName: 'customers', recordId: [1], stepIndex: 0, - values: {}, - } as RecordData, + } as RecordRef, model: makeMockModel().model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], 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 11faca09e..06cea9508 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 @@ -1,7 +1,8 @@ +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 } from '../../src/types/record'; +import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/record'; import type { AiTaskStepDefinition } from '../../src/types/step-definition'; import type { AiTaskStepHistory } from '../../src/types/step-history'; @@ -27,6 +28,15 @@ function makeStepHistory(overrides: Partial = {}): AiTaskStep }; } +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + function makeRecord(overrides: Partial = {}): RecordData { return { collectionName: 'customers', @@ -41,6 +51,23 @@ function makeRecord(overrides: Partial = {}): RecordData { }; } +function makeMockAgentPort( + recordsByCollection: Record }> = { + customers: { values: { email: 'john@example.com', name: 'John Doe', orders: null } }, + }, +): AgentPort { + return { + getRecord: jest + .fn() + .mockImplementation((collectionName: string) => + Promise.resolve(recordsByCollection[collectionName] ?? { values: {} }), + ), + updateRecord: jest.fn(), + getRelatedData: jest.fn(), + executeAction: jest.fn(), + } as unknown as AgentPort; +} + function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { return { collectionName: 'customers', @@ -99,9 +126,9 @@ function makeMockModel( function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', - baseRecord: makeRecord(), + baseRecord: makeRecordRef(), model: makeMockModel({ fieldNames: ['email'] }).model, - agentPort: {} as ExecutionContext['agentPort'], + agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), history: [], @@ -254,7 +281,7 @@ describe('ReadRecordStepExecutor', () => { describe('multi-record AI selection', () => { it('uses AI to select among multiple records then reads fields', async () => { - const baseRecord = makeRecord({ stepIndex: 1 }); + const baseRecord = makeRecordRef({ stepIndex: 1 }); const relatedRecord = makeRecord({ stepIndex: 2, recordId: [99], @@ -303,7 +330,10 @@ describe('ReadRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ baseRecord, model, runStore, workflowPort }); + const agentPort = makeMockAgentPort({ + customers: { values: { email: 'john@example.com', name: 'John Doe', orders: null } }, + }); + const context = makeContext({ baseRecord, model, runStore, workflowPort, agentPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -339,7 +369,7 @@ describe('ReadRecordStepExecutor', () => { }); it('reads fields from the second record when AI selects it', async () => { - const baseRecord = makeRecord({ stepIndex: 1 }); + const baseRecord = makeRecordRef({ stepIndex: 1 }); const relatedRecord = makeRecord({ stepIndex: 2, recordId: [99], @@ -383,7 +413,10 @@ describe('ReadRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ baseRecord, model, runStore, workflowPort }); + const agentPort = makeMockAgentPort({ + orders: { values: { total: 150 } }, + }); + const context = makeContext({ baseRecord, model, runStore, workflowPort, agentPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -403,7 +436,7 @@ describe('ReadRecordStepExecutor', () => { }); it('includes step index in select-record tool schema when records have stepIndex', async () => { - const baseRecord = makeRecord({ stepIndex: 3 }); + const baseRecord = makeRecordRef({ stepIndex: 3 }); const relatedRecord = makeRecord({ stepIndex: 5, recordId: [99], @@ -465,7 +498,7 @@ describe('ReadRecordStepExecutor', () => { describe('AI record selection failure', () => { it('returns error when AI selects a non-existent record identifier', async () => { - const baseRecord = makeRecord(); + const baseRecord = makeRecordRef(); const relatedRecord = makeRecord({ stepIndex: 1, recordId: [99], @@ -511,6 +544,25 @@ describe('ReadRecordStepExecutor', () => { }); }); + describe('agentPort.getRecord error', () => { + it('returns error when agentPort.getRecord throws', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getRecord as jest.Mock).mockRejectedValue( + new Error('Record not found: collection "customers", id "42"'), + ); + const mockModel = makeMockModel({ fieldNames: ['email'] }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore, agentPort }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe('Record not found: collection "customers", id "42"'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + describe('model error', () => { it('returns error status when AI invocation fails', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); From 33e555218842193e669723b8236aa5627777eac0 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 18:11:51 +0100 Subject: [PATCH 23/37] fix(workflow-executor): update adapter to use renamed types after rebase Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/forest-server-workflow-port.ts | 11 ++++++----- .../workflow-executor/src/ports/workflow-port.ts | 2 +- .../adapters/forest-server-workflow-port.test.ts | 15 ++++++++------- .../executors/read-record-step-executor.test.ts | 2 +- 4 files changed, 16 insertions(+), 14 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 e804e01cf..3fff9bc8a 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -1,16 +1,17 @@ import type { McpConfiguration, WorkflowPort } from '../ports/workflow-port'; import type { PendingStepExecution } from '../types/execution'; -import type { CollectionRef } from '../types/record'; +import type { CollectionSchema } from '../types/record'; import type { StepHistory } from '../types/step-history'; import type { HttpOptions } from '@forestadmin/forestadmin-client'; +// @ts-expect-error -- ServerUtils is not yet re-exported from the built package 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', updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`, - collectionRef: (collectionName: string) => `/liana/v1/collections/${collectionName}`, + collectionSchema: (collectionName: string) => `/liana/v1/collections/${collectionName}`, mcpServerConfigs: '/liana/mcp-server-configs-with-details', }; @@ -39,11 +40,11 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); } - async getCollectionRef(collectionName: string): Promise { - return ServerUtils.query( + async getCollectionSchema(collectionName: string): Promise { + return ServerUtils.query( this.options, 'get', - ROUTES.collectionRef(collectionName), + ROUTES.collectionSchema(collectionName), ); } diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index c70a60c9f..ea4be27bb 100644 --- a/packages/workflow-executor/src/ports/workflow-port.ts +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -9,7 +9,7 @@ export type McpConfiguration = unknown; export interface WorkflowPort { getPendingStepExecutions(): Promise; - completeStepExecution(runId: string, stepHistory: StepHistory): Promise; + updateStepExecution(runId: string, stepHistory: StepHistory): Promise; getCollectionSchema(collectionName: string): Promise; getMcpServerConfigs(): Promise; } 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 ff37147e7..2da72bde4 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -1,7 +1,8 @@ import type { PendingStepExecution } from '../../src/types/execution'; -import type { CollectionRef } from '../../src/types/record'; +import type { CollectionSchema } from '../../src/types/record'; import type { StepHistory } from '../../src/types/step-history'; +// @ts-expect-error -- ServerUtils is not yet re-exported from the built package import { ServerUtils } from '@forestadmin/forestadmin-client'; import ForestServerWorkflowPort from '../../src/adapters/forest-server-workflow-port'; @@ -61,21 +62,21 @@ describe('ForestServerWorkflowPort', () => { }); }); - describe('getCollectionRef', () => { - it('should fetch the collection ref by name', async () => { - const collectionRef: CollectionRef = { + describe('getCollectionSchema', () => { + it('should fetch the collection schema by name', async () => { + const collectionSchema: CollectionSchema = { collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], fields: [], actions: [], }; - mockQuery.mockResolvedValue(collectionRef); + mockQuery.mockResolvedValue(collectionSchema); - const result = await port.getCollectionRef('users'); + const result = await port.getCollectionSchema('users'); expect(mockQuery).toHaveBeenCalledWith(options, 'get', '/liana/v1/collections/users'); - expect(result).toEqual(collectionRef); + expect(result).toEqual(collectionSchema); }); }); 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 06cea9508..2f806691b 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 @@ -98,7 +98,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), - completeStepExecution: jest.fn().mockResolvedValue(undefined), + updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() .mockImplementation((name: string) => From c8a12c261a1006734947be57dfc84acbb624a187 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 18:40:17 +0100 Subject: [PATCH 24/37] refactor(workflow-executor): narrow catch block and clean up RecordData type - Only catch WorkflowExecutorError in execute(), re-throw infrastructure errors - Redefine RecordData as Omit & { values } - Change LoadRelatedRecordStepExecutionData.record to RecordRef - Use RecordData directly in AgentPort (remove AgentRecord alias) - Update tests: model errors propagate, add RecordNotFoundError test Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 6 +- .../workflow-executor/src/ports/agent-port.ts | 8 +-- .../workflow-executor/src/types/record.ts | 6 +- .../src/types/step-execution-data.ts | 4 +- .../read-record-step-executor.test.ts | 60 +++++++------------ 5 files changed, 35 insertions(+), 49 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 902ac03d1..aacba651c 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -47,7 +47,11 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< ); values = agentRecord.values; } catch (error) { - return { stepHistory: { ...stepHistory, status: 'error', error: (error as Error).message } }; + if (error instanceof WorkflowExecutorError) { + return { stepHistory: { ...stepHistory, status: 'error', error: error.message } }; + } + + throw error; } const fieldResults = this.readFieldValues(values, schema, fieldNames); diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 3e65475ea..8a05c918d 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -2,20 +2,18 @@ import type { RecordData } from '../types/record'; -type AgentRecord = Omit; - export interface AgentPort { - getRecord(collectionName: string, recordId: Array): Promise; + getRecord(collectionName: string, recordId: Array): Promise; updateRecord( collectionName: string, recordId: Array, values: Record, - ): Promise; + ): Promise; getRelatedData( collectionName: string, recordId: Array, relationName: string, - ): Promise; + ): Promise; executeAction( collectionName: string, actionName: string, diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 3f8b22511..b5070c39f 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -31,7 +31,5 @@ export interface RecordRef { stepIndex: number; } -/** A record with its loaded field values. */ -export interface RecordData extends RecordRef { - values: Record; -} +/** A record with its loaded field values — no stepIndex (agent doesn't know about steps). */ +export type RecordData = Omit & { values: Record }; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 1f2fe6c45..eb022a273 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,6 +1,6 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -import type { RecordData, RecordRef } from './record'; +import type { RecordRef } from './record'; // -- Base -- @@ -53,7 +53,7 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { type: 'load-related-record'; - record: RecordData; + record: RecordRef; } // -- Union -- 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 2f806691b..fa4a39502 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 @@ -2,10 +2,11 @@ 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 { CollectionSchema, RecordRef } from '../../src/types/record'; import type { AiTaskStepDefinition } from '../../src/types/step-definition'; import type { AiTaskStepHistory } from '../../src/types/step-history'; +import { RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -37,20 +38,6 @@ function makeRecordRef(overrides: Partial = {}): RecordRef { }; } -function makeRecord(overrides: Partial = {}): RecordData { - return { - collectionName: 'customers', - recordId: [42], - stepIndex: 0, - values: { - email: 'john@example.com', - name: 'John Doe', - orders: null, - }, - ...overrides, - }; -} - function makeMockAgentPort( recordsByCollection: Record }> = { customers: { values: { email: 'john@example.com', name: 'John Doe', orders: null } }, @@ -282,11 +269,10 @@ describe('ReadRecordStepExecutor', () => { describe('multi-record AI selection', () => { it('uses AI to select among multiple records then reads fields', async () => { const baseRecord = makeRecordRef({ stepIndex: 1 }); - const relatedRecord = makeRecord({ + const relatedRecord = makeRecordRef({ stepIndex: 2, recordId: [99], collectionName: 'orders', - values: { total: 150 }, }); const ordersSchema = makeCollectionSchema({ @@ -330,10 +316,7 @@ describe('ReadRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }); - const agentPort = makeMockAgentPort({ - customers: { values: { email: 'john@example.com', name: 'John Doe', orders: null } }, - }); - const context = makeContext({ baseRecord, model, runStore, workflowPort, agentPort }); + const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(makeStep(), makeStepHistory()); @@ -370,11 +353,10 @@ describe('ReadRecordStepExecutor', () => { it('reads fields from the second record when AI selects it', async () => { const baseRecord = makeRecordRef({ stepIndex: 1 }); - const relatedRecord = makeRecord({ + const relatedRecord = makeRecordRef({ stepIndex: 2, recordId: [99], collectionName: 'orders', - values: { total: 150 }, }); const ordersSchema = makeCollectionSchema({ @@ -437,11 +419,10 @@ describe('ReadRecordStepExecutor', () => { it('includes step index in select-record tool schema when records have stepIndex', async () => { const baseRecord = makeRecordRef({ stepIndex: 3 }); - const relatedRecord = makeRecord({ + const relatedRecord = makeRecordRef({ stepIndex: 5, recordId: [99], collectionName: 'orders', - values: { total: 150 }, }); const ordersSchema = makeCollectionSchema({ @@ -499,11 +480,10 @@ describe('ReadRecordStepExecutor', () => { describe('AI record selection failure', () => { it('returns error when AI selects a non-existent record identifier', async () => { const baseRecord = makeRecordRef(); - const relatedRecord = makeRecord({ + const relatedRecord = makeRecordRef({ stepIndex: 1, recordId: [99], collectionName: 'orders', - values: { total: 150 }, }); const ordersSchema = makeCollectionSchema({ @@ -545,10 +525,10 @@ describe('ReadRecordStepExecutor', () => { }); describe('agentPort.getRecord error', () => { - it('returns error when agentPort.getRecord throws', async () => { + it('returns error when agentPort.getRecord throws a WorkflowExecutorError', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRecord as jest.Mock).mockRejectedValue( - new Error('Record not found: collection "customers", id "42"'), + new RecordNotFoundError('customers', '42'), ); const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore(); @@ -561,24 +541,30 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepHistory.error).toBe('Record not found: collection "customers", id "42"'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); + + 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 context = makeContext({ model: mockModel.model, agentPort }); + const executor = new ReadRecordStepExecutor(context); + + await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow( + 'Connection refused', + ); + }); }); describe('model error', () => { - it('returns error status when AI invocation fails', async () => { + it('lets non-WorkflowExecutorError propagate from AI invocation', 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 ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); - - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe('API timeout'); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('API timeout'); }); }); From ac1e6c7362f3fc730c6fb7d80dbb1fd8da2613d1 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 19:08:01 +0100 Subject: [PATCH 25/37] refactor(workflow-executor): move step and stepHistory into ExecutionContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make ExecutionContext generic with TStep/THistory defaults so executors read step and stepHistory from context instead of execute() arguments. Also rename selectRecord → selectRecordRef and getAvailableRecords → getAvailableRecordRefs for consistency. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 6 +- .../src/executors/condition-step-executor.ts | 7 +- .../executors/read-record-step-executor.ts | 14 ++-- .../workflow-executor/src/types/execution.ts | 8 +- .../test/executors/base-step-executor.test.ts | 12 +++ .../executors/condition-step-executor.test.ts | 62 +++++++++------- .../read-record-step-executor.test.ts | 74 ++++++++++--------- 7 files changed, 109 insertions(+), 74 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index a6eedd163..39dd82031 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -14,13 +14,13 @@ export default abstract class BaseStepExecutor< TStep extends StepDefinition = StepDefinition, THistory extends StepHistory = StepHistory, > { - protected readonly context: ExecutionContext; + protected readonly context: ExecutionContext; - constructor(context: ExecutionContext) { + constructor(context: ExecutionContext) { this.context = context; } - abstract execute(step: TStep, stepHistory: THistory): Promise; + abstract execute(): Promise; /** * Returns a SystemMessage array summarizing previously executed steps. diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index b90d47ad8..e18be98a0 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -40,10 +40,9 @@ export default class ConditionStepExecutor extends BaseStepExecutor< ConditionStepDefinition, ConditionStepHistory > { - async execute( - step: ConditionStepDefinition, - stepHistory: ConditionStepHistory, - ): Promise { + async execute(): Promise { + const { step, stepHistory } = this.context; + const tool = new DynamicStructuredTool({ name: 'choose-gateway-option', description: 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 aacba651c..92572c598 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -26,11 +26,9 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< AiTaskStepDefinition, AiTaskStepHistory > { - async execute( - step: AiTaskStepDefinition, - stepHistory: AiTaskStepHistory, - ): Promise { - const records = await this.getAvailableRecords(); + async execute(): Promise { + const { step, stepHistory } = this.context; + const records = await this.getAvailableRecordRefs(); let selectedRef: RecordRef; let schema: CollectionSchema; @@ -38,7 +36,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< let values: Record; try { - selectedRef = await this.selectRecord(records, step.prompt); + selectedRef = await this.selectRecordRef(records, step.prompt); schema = await this.context.workflowPort.getCollectionSchema(selectedRef.collectionName); fieldNames = await this.selectFields(schema, step.prompt); const agentRecord = await this.context.agentPort.getRecord( @@ -90,7 +88,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return args.fieldNames; } - private async selectRecord(records: RecordRef[], prompt: string | undefined): Promise { + private async selectRecordRef(records: RecordRef[], prompt: string | undefined): Promise { if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; @@ -178,7 +176,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private async getAvailableRecords(): Promise { + private async getAvailableRecordRefs(): Promise { const stepExecutions = await this.context.runStore.getStepExecutions(); const relatedRecords = stepExecutions .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index f9124f0fc..5b75eccf7 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -17,6 +17,7 @@ export type UserInput = { type: 'confirmation'; confirmed: boolean }; export interface PendingStepExecution { readonly runId: string; + readonly baseRecord: RecordRef; readonly step: StepDefinition; readonly stepHistory: StepHistory; readonly previousSteps: ReadonlyArray; @@ -27,9 +28,14 @@ export interface StepExecutionResult { stepHistory: StepHistory; } -export interface ExecutionContext { +export interface ExecutionContext< + TStep extends StepDefinition = StepDefinition, + THistory extends StepHistory = StepHistory, +> { readonly runId: string; readonly baseRecord: RecordRef; + readonly step: TStep; + readonly stepHistory: THistory; readonly model: BaseChatModel; readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; 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 2fa422503..665256f8e 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -63,6 +63,18 @@ function makeContext(overrides: Partial = {}): ExecutionContex recordId: [1], stepIndex: 0, } as RecordRef, + step: { + id: 'step-0', + type: StepType.Condition, + options: ['A', 'B'], + prompt: 'Pick one', + }, + stepHistory: { + type: 'condition', + stepId: 'step-0', + stepIndex: 0, + status: 'success', + }, model: {} as ExecutionContext['model'], agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], 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 6b5b82fdb..b28c76ed1 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -47,7 +47,9 @@ function makeMockModel(toolCallArgs?: Record) { return { model, bindTools, invoke }; } -function makeContext(overrides: Partial = {}): ExecutionContext { +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', baseRecord: { @@ -55,6 +57,8 @@ function makeContext(overrides: Partial = {}): ExecutionContex recordId: [1], stepIndex: 0, } as RecordRef, + step: makeStep(), + stepHistory: makeStepHistory(), model: makeMockModel().model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], @@ -74,9 +78,11 @@ describe('ConditionStepExecutor', () => { question: 'Approve?', }); const stepHistory = makeStepHistory(); - const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model })); + const executor = new ConditionStepExecutor( + makeContext({ model: mockModel.model, stepHistory }), + ); - const result = await executor.execute(makeStep(), stepHistory); + const result = await executor.execute(); expect(result.stepHistory).not.toBe(stepHistory); expect(stepHistory.status).toBe('success'); @@ -98,7 +104,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect((result.stepHistory as ConditionStepHistory).selectedOption).toBe('Reject'); @@ -122,13 +128,15 @@ describe('ConditionStepExecutor', () => { reasoning: 'Looks good', question: 'Should we?', }); - const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model })); - - await executor.execute( - makeStep({ options: ['Approve', 'Reject', 'Defer'] }), - makeStepHistory(), + const executor = new ConditionStepExecutor( + makeContext({ + model: mockModel.model, + step: makeStep({ options: ['Approve', 'Reject', 'Defer'] }), + }), ); + await executor.execute(); + const tool = mockModel.bindTools.mock.calls[0][0][0]; expect(tool.name).toBe('choose-gateway-option'); expect(tool.schema.parse({ option: 'Approve', reasoning: 'r', question: 'q' })).toBeTruthy(); @@ -145,13 +153,13 @@ describe('ConditionStepExecutor', () => { reasoning: 'Looks good', question: 'Should we approve?', }); - const context = makeContext({ model: mockModel.model }); + const context = makeContext({ + model: mockModel.model, + step: makeStep({ prompt: 'Custom prompt for this step' }), + }); const executor = new ConditionStepExecutor(context); - await executor.execute( - makeStep({ prompt: 'Custom prompt for this step' }), - makeStepHistory(), - ); + await executor.execute(); const messages = mockModel.invoke.mock.calls[0][0]; expect(messages).toHaveLength(2); @@ -166,10 +174,13 @@ describe('ConditionStepExecutor', () => { reasoning: 'Default', question: 'Approve?', }); - const context = makeContext({ model: mockModel.model }); + const context = makeContext({ + model: mockModel.model, + step: makeStep({ prompt: undefined }), + }); const executor = new ConditionStepExecutor(context); - await executor.execute(makeStep({ prompt: undefined }), makeStepHistory()); + await executor.execute(); const messages = mockModel.invoke.mock.calls[0][0]; const humanMessage = messages[messages.length - 1]; @@ -211,12 +222,13 @@ describe('ConditionStepExecutor', () => { }, ], }); - const executor = new ConditionStepExecutor(context); + const executor = new ConditionStepExecutor({ + ...context, + step: makeStep({ id: 'cond-2' }), + stepHistory: makeStepHistory({ stepId: 'cond-2', stepIndex: 1 }), + }); - await executor.execute( - makeStep({ id: 'cond-2' }), - makeStepHistory({ stepId: 'cond-2', stepIndex: 1 }), - ); + await executor.execute(); const messages = mockModel.invoke.mock.calls[0][0]; expect(messages).toHaveLength(3); @@ -241,7 +253,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('manual-decision'); expect(result.stepHistory.error).toBeUndefined(); @@ -269,7 +281,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe( @@ -288,7 +300,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe('API timeout'); @@ -305,7 +317,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model, runStore })); - await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('Storage full'); + await expect(executor.execute()).rejects.toThrow('Storage full'); }); }); }); 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 fa4a39502..adb353046 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 @@ -110,10 +110,14 @@ function makeMockModel( return { model, bindTools, invoke }; } -function makeContext(overrides: Partial = {}): ExecutionContext { +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', baseRecord: makeRecordRef(), + step: makeStep(), + stepHistory: makeStepHistory(), model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), @@ -132,7 +136,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -155,7 +159,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -179,7 +183,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -199,7 +203,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -226,7 +230,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await executor.execute(makeStep(), makeStepHistory()); + await executor.execute(); const tool = mockModel.bindTools.mock.calls[0][0][0]; expect(tool.name).toBe('read-selected-record-fields'); @@ -256,7 +260,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe( @@ -319,7 +323,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(bindTools).toHaveBeenCalledTimes(2); @@ -401,7 +405,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ baseRecord, model, runStore, workflowPort, agentPort }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( @@ -465,7 +469,7 @@ describe('ReadRecordStepExecutor', () => { makeContext({ baseRecord, model, runStore, workflowPort }), ); - await executor.execute(makeStep(), makeStepHistory()); + await executor.execute(); const selectTool = bindTools.mock.calls[0][0][0]; const schemaShape = selectTool.schema.shape; @@ -514,7 +518,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ baseRecord, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe( @@ -535,7 +539,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, agentPort }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe('Record not found: collection "customers", id "42"'); @@ -549,9 +553,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, agentPort }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow( - 'Connection refused', - ); + await expect(executor.execute()).rejects.toThrow('Connection refused'); }); }); @@ -564,7 +566,7 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('API timeout'); + await expect(executor.execute()).rejects.toThrow('API timeout'); }); }); @@ -584,7 +586,7 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe( @@ -603,7 +605,7 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), makeStepHistory()); + const result = await executor.execute(); expect(result.stepHistory.status).toBe('error'); expect(result.stepHistory.error).toBe('AI did not return a tool call'); @@ -620,7 +622,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('Storage full'); + await expect(executor.execute()).rejects.toThrow('Storage full'); }); it('lets getStepExecutions errors propagate', async () => { @@ -631,9 +633,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow( - 'Connection lost', - ); + await expect(executor.execute()).rejects.toThrow('Connection lost'); }); }); @@ -641,10 +641,10 @@ describe('ReadRecordStepExecutor', () => { it('does not mutate the input stepHistory', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const stepHistory = makeStepHistory(); - const context = makeContext({ model: mockModel.model }); + const context = makeContext({ model: mockModel.model, stepHistory }); const executor = new ReadRecordStepExecutor(context); - const result = await executor.execute(makeStep(), stepHistory); + const result = await executor.execute(); expect(result.stepHistory).not.toBe(stepHistory); expect(stepHistory.status).toBe('success'); @@ -683,12 +683,13 @@ describe('ReadRecordStepExecutor', () => { }, ], }); - const executor = new ReadRecordStepExecutor(context); + const executor = new ReadRecordStepExecutor({ + ...context, + step: makeStep({ id: 'read-2' }), + stepHistory: makeStepHistory({ stepId: 'read-2', stepIndex: 1 }), + }); - await executor.execute( - makeStep({ id: 'read-2' }), - makeStepHistory({ stepId: 'read-2', stepIndex: 1 }), - ); + await executor.execute(); const messages = mockModel.invoke.mock.calls[0][0]; // previous steps summary + system prompt + collection info + human message = 4 @@ -702,10 +703,13 @@ describe('ReadRecordStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); - const context = makeContext({ model: mockModel.model }); + const context = makeContext({ + model: mockModel.model, + step: makeStep({ prompt: undefined }), + }); const executor = new ReadRecordStepExecutor(context); - await executor.execute(makeStep({ prompt: undefined }), makeStepHistory()); + await executor.execute(); const messages = mockModel.invoke.mock.calls[0][0]; const humanMessage = messages[messages.length - 1]; @@ -717,10 +721,14 @@ describe('ReadRecordStepExecutor', () => { it('saves executionParams, executionResult, and selectedRecord', async () => { const mockModel = makeMockModel({ fieldNames: ['email', 'name'] }); const runStore = makeMockRunStore(); - const context = makeContext({ model: mockModel.model, runStore }); + const context = makeContext({ + model: mockModel.model, + runStore, + stepHistory: makeStepHistory({ stepIndex: 3 }), + }); const executor = new ReadRecordStepExecutor(context); - await executor.execute(makeStep(), makeStepHistory({ stepIndex: 3 })); + await executor.execute(); expect(runStore.saveStepExecution).toHaveBeenCalledWith({ type: 'read-record', From ba6ac85fae26687983b92b8d037ebb46d3e79fb9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 19:09:28 +0100 Subject: [PATCH 26/37] refactor(workflow-executor): rename agentRecord and readFieldValues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename agentRecord → recordData and readFieldValues → formatFieldResults for clarity. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 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 92572c598..97e8266d6 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -39,11 +39,11 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< selectedRef = await this.selectRecordRef(records, step.prompt); schema = await this.context.workflowPort.getCollectionSchema(selectedRef.collectionName); fieldNames = await this.selectFields(schema, step.prompt); - const agentRecord = await this.context.agentPort.getRecord( + const recordData = await this.context.agentPort.getRecord( selectedRef.collectionName, selectedRef.recordId, ); - values = agentRecord.values; + values = recordData.values; } catch (error) { if (error instanceof WorkflowExecutorError) { return { stepHistory: { ...stepHistory, status: 'error', error: error.message } }; @@ -52,7 +52,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< throw error; } - const fieldResults = this.readFieldValues(values, schema, fieldNames); + const fieldResults = this.formatFieldResults(values, schema, fieldNames); await this.context.runStore.saveStepExecution({ type: 'read-record', @@ -144,7 +144,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< description: 'Read one or more fields from the selected record.', schema: z.object({ // z.string() (not z.enum) intentionally: an invalid field name in the array - // does not fail the whole tool call — per-field errors are handled in readFieldValues. + // does not fail the whole tool call — per-field errors are handled in formatFieldResults. // This matches the frontend implementation (ISO frontend). fieldNames: z .array(z.string()) @@ -158,7 +158,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< }); } - private readFieldValues( + private formatFieldResults( values: Record, schema: CollectionSchema, fieldNames: string[], From 23d8413717bd2e10a695d79a2c88e99cda34854d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 19:49:29 +0100 Subject: [PATCH 27/37] fix(workflow-executor): guard against undefined executionParams in step summary Add null check before stringifying executionParams in buildStepSummary, since AiTaskStepExecutionData.executionParams is optional. Also fix prettier issue on selectRecordRef signature. Co-Authored-By: Claude Opus 4.6 --- .../workflow-executor/src/executors/base-step-executor.ts | 4 +++- .../src/executors/read-record-step-executor.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 39dd82031..3fa8ad566 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -62,7 +62,9 @@ export default abstract class BaseStepExecutor< const lines = [header, ` Prompt: ${prompt}`]; if (isExecutedStepOnExecutor(execution)) { - lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); + if (execution.executionParams !== undefined) { + lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); + } if (execution.executionResult) { lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); 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 97e8266d6..ede007db1 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -88,7 +88,10 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< return args.fieldNames; } - private async selectRecordRef(records: RecordRef[], prompt: string | undefined): Promise { + private async selectRecordRef( + records: RecordRef[], + prompt: string | undefined, + ): Promise { if (records.length === 0) throw new NoRecordsError(); if (records.length === 1) return records[0]; From c4ab6134a516a8d4893fcd15625b2e3a2f914d19 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 20:08:06 +0100 Subject: [PATCH 28/37] chore(workflow-executor): remove obsolete ts-expect-error on ServerUtils import ServerUtils is now properly exported from @forestadmin/forestadmin-client. Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/forest-server-workflow-port.ts | 1 - .../test/adapters/forest-server-workflow-port.test.ts | 1 - 2 files changed, 2 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 3fff9bc8a..118b62782 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -4,7 +4,6 @@ import type { CollectionSchema } from '../types/record'; import type { StepHistory } from '../types/step-history'; import type { HttpOptions } from '@forestadmin/forestadmin-client'; -// @ts-expect-error -- ServerUtils is not yet re-exported from the built package import { ServerUtils } from '@forestadmin/forestadmin-client'; // TODO: finalize route paths with the team — these are placeholders 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 2da72bde4..37613f3d4 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 @@ -2,7 +2,6 @@ import type { PendingStepExecution } from '../../src/types/execution'; import type { CollectionSchema } from '../../src/types/record'; import type { StepHistory } from '../../src/types/step-history'; -// @ts-expect-error -- ServerUtils is not yet re-exported from the built package import { ServerUtils } from '@forestadmin/forestadmin-client'; import ForestServerWorkflowPort from '../../src/adapters/forest-server-workflow-port'; From fcec37142fbbc8a38ea342c9ab367f66b1417176 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 22:06:45 +0100 Subject: [PATCH 29/37] test(workflow-executor): cover executionParams guard and NoRecordsError Add test for undefined executionParams in step summary and instantiate NoRecordsError to meet diff coverage threshold. Co-Authored-By: Claude Opus 4.6 --- .../test/executors/base-step-executor.test.ts | 27 +++++++++++++++++++ .../read-record-step-executor.test.ts | 11 +++++++- 2 files changed, 37 insertions(+), 1 deletion(-) 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 665256f8e..f982cbd7c 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -308,6 +308,33 @@ describe('BaseStepExecutor', () => { expect(result).not.toContain('History:'); }); + it('omits Input line when executionParams is undefined', async () => { + const entry: { step: StepDefinition; stepHistory: StepHistory } = { + step: { id: 'ai-step', type: StepType.ReadRecord, prompt: 'Do something' }, + stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'success' }, + }; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([ + { + type: 'ai-task', + stepIndex: 0, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "ai-step"'); + expect(result).toContain('Prompt: Do something'); + expect(result).not.toContain('Input:'); + }); + it('shows "(no prompt)" when step has no prompt', async () => { const entry = makeHistoryEntry({ stepIndex: 0 }); entry.step.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 adb353046..f7ac47f1b 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 @@ -6,7 +6,7 @@ import type { CollectionSchema, RecordRef } from '../../src/types/record'; import type { AiTaskStepDefinition } from '../../src/types/step-definition'; import type { AiTaskStepHistory } from '../../src/types/step-history'; -import { RecordNotFoundError } from '../../src/errors'; +import { NoRecordsError, RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; import { StepType } from '../../src/types/step-definition'; @@ -249,6 +249,15 @@ describe('ReadRecordStepExecutor', () => { }); }); + describe('no records available', () => { + it('returns error when no records are available', () => { + const error = new NoRecordsError(); + + expect(error).toBeInstanceOf(NoRecordsError); + expect(error.message).toBe('No records available'); + }); + }); + describe('no readable fields', () => { it('returns error when all fields are relationships', async () => { const schema = makeCollectionSchema({ From 20368db5d39c914357aba91f9f8122bb86f076d9 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 18 Mar 2026 22:18:17 +0100 Subject: [PATCH 30/37] refactor(workflow-executor): use currentStep StepRecord in PendingStepExecution Replace separate step/stepHistory fields with currentStep: StepRecord for consistency with the existing StepRecord type. Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/src/types/execution.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 5b75eccf7..060efa734 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -18,8 +18,7 @@ export type UserInput = { type: 'confirmation'; confirmed: boolean }; export interface PendingStepExecution { readonly runId: string; readonly baseRecord: RecordRef; - readonly step: StepDefinition; - readonly stepHistory: StepHistory; + readonly currentStep: StepRecord; readonly previousSteps: ReadonlyArray; readonly userInput?: UserInput; } From f9a6fbd354d77503555712f3b263e25f7cc7142d Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 09:17:20 +0100 Subject: [PATCH 31/37] refactor(workflow-executor): remove stepHistory from ExecutionContext and add stepIndex to StepDefinition Executors now construct StepHistory internally instead of receiving it via context. Also removes unused StepCategory type and simplifies selectedRecordRef spread. Co-Authored-By: Claude Opus 4.6 --- .../src/executors/base-step-executor.ts | 11 ++-- .../src/executors/condition-step-executor.ts | 31 +++++++---- .../executors/read-record-step-executor.ts | 49 ++++++++++------- packages/workflow-executor/src/index.ts | 1 - .../workflow-executor/src/types/execution.ts | 12 ++-- .../src/types/step-definition.ts | 9 +-- .../test/executors/base-step-executor.test.ts | 16 ++---- .../executors/condition-step-executor.test.ts | 42 ++------------ .../read-record-step-executor.test.ts | 55 +++++-------------- 9 files changed, 87 insertions(+), 139 deletions(-) diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 3fa8ad566..8355f6774 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -10,13 +10,10 @@ import { SystemMessage } from '@langchain/core/messages'; import { MalformedToolCallError, MissingToolCallError } from '../errors'; import { isExecutedStepOnExecutor } from '../types/step-execution-data'; -export default abstract class BaseStepExecutor< - TStep extends StepDefinition = StepDefinition, - THistory extends StepHistory = StepHistory, -> { - protected readonly context: ExecutionContext; +export default abstract class BaseStepExecutor { + protected readonly context: ExecutionContext; - constructor(context: ExecutionContext) { + constructor(context: ExecutionContext) { this.context = context; } @@ -58,7 +55,7 @@ export default abstract class BaseStepExecutor< execution: StepExecutionData | undefined, ): string { const prompt = step.prompt ?? '(no prompt)'; - const header = `Step "${step.id}" (index ${stepHistory.stepIndex}):`; + const header = `Step "${step.id}" (index ${step.stepIndex}):`; const lines = [header, ` Prompt: ${prompt}`]; if (isExecutedStepOnExecutor(execution)) { diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index e18be98a0..2c2e183ce 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -1,6 +1,5 @@ import type { StepExecutionResult } from '../types/execution'; import type { ConditionStepDefinition } from '../types/step-definition'; -import type { ConditionStepHistory } from '../types/step-history'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; @@ -36,12 +35,9 @@ const GATEWAY_SYSTEM_PROMPT = `You are an AI agent selecting the correct option - If selecting null: explain why options don't match the question - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; -export default class ConditionStepExecutor extends BaseStepExecutor< - ConditionStepDefinition, - ConditionStepHistory -> { +export default class ConditionStepExecutor extends BaseStepExecutor { async execute(): Promise { - const { step, stepHistory } = this.context; + const { step } = this.context; const tool = new DynamicStructuredTool({ name: 'choose-gateway-option', @@ -73,7 +69,9 @@ export default class ConditionStepExecutor extends BaseStepExecutor< } catch (error: unknown) { return { stepHistory: { - ...stepHistory, + type: 'condition', + stepId: step.id, + stepIndex: step.stepIndex, status: 'error', error: (error as Error).message, }, @@ -84,17 +82,30 @@ export default class ConditionStepExecutor extends BaseStepExecutor< await this.context.runStore.saveStepExecution({ type: 'condition', - stepIndex: stepHistory.stepIndex, + stepIndex: step.stepIndex, executionParams: { answer: selectedOption, reasoning }, executionResult: selectedOption ? { answer: selectedOption } : undefined, }); if (!selectedOption) { - return { stepHistory: { ...stepHistory, status: 'manual-decision' } }; + return { + stepHistory: { + type: 'condition', + stepId: step.id, + stepIndex: step.stepIndex, + status: 'manual-decision', + }, + }; } return { - stepHistory: { ...stepHistory, status: 'success', selectedOption }, + stepHistory: { + type: 'condition', + stepId: step.id, + stepIndex: step.stepIndex, + status: 'success', + selectedOption, + }, }; } } 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 ede007db1..7ef0c97f3 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -5,7 +5,6 @@ import type { FieldReadResult, LoadRelatedRecordStepExecutionData, } from '../types/step-execution-data'; -import type { AiTaskStepHistory } from '../types/step-history'; import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; @@ -22,31 +21,38 @@ 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 ReadRecordStepExecutor extends BaseStepExecutor< - AiTaskStepDefinition, - AiTaskStepHistory -> { +export default class ReadRecordStepExecutor extends BaseStepExecutor { async execute(): Promise { - const { step, stepHistory } = this.context; + const { step } = this.context; const records = await this.getAvailableRecordRefs(); - let selectedRef: RecordRef; + let selectedRecordRef: RecordRef; let schema: CollectionSchema; let fieldNames: string[]; let values: Record; try { - selectedRef = await this.selectRecordRef(records, step.prompt); - schema = await this.context.workflowPort.getCollectionSchema(selectedRef.collectionName); + selectedRecordRef = await this.selectRecordRef(records, step.prompt); + schema = await this.context.workflowPort.getCollectionSchema( + selectedRecordRef.collectionName, + ); fieldNames = await this.selectFields(schema, step.prompt); const recordData = await this.context.agentPort.getRecord( - selectedRef.collectionName, - selectedRef.recordId, + selectedRecordRef.collectionName, + selectedRecordRef.recordId, ); values = recordData.values; } catch (error) { if (error instanceof WorkflowExecutorError) { - return { stepHistory: { ...stepHistory, status: 'error', error: error.message } }; + return { + stepHistory: { + type: 'ai-task', + stepId: step.id, + stepIndex: step.stepIndex, + status: 'error', + error: error.message, + }, + }; } throw error; @@ -56,17 +62,20 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< await this.context.runStore.saveStepExecution({ type: 'read-record', - stepIndex: stepHistory.stepIndex, + stepIndex: step.stepIndex, executionParams: { fieldNames }, executionResult: { fields: fieldResults }, - selectedRecordRef: { - collectionName: selectedRef.collectionName, - recordId: selectedRef.recordId, - stepIndex: selectedRef.stepIndex, - }, + selectedRecordRef, }); - return { stepHistory: { ...stepHistory, status: 'success' } }; + return { + stepHistory: { + type: 'ai-task', + stepId: step.id, + stepIndex: step.stepIndex, + status: 'success', + }, + }; } private async selectFields( @@ -185,7 +194,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor< .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') .map(e => e.record); - return [this.context.baseRecord, ...relatedRecords]; + return [this.context.baseRecordRef, ...relatedRecords]; } private async toRecordIdentifier(record: RecordRef): Promise { diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 22a612b6a..32ad74d5f 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -1,6 +1,5 @@ export { StepType } from './types/step-definition'; export type { - StepCategory, ConditionStepDefinition, AiTaskStepDefinition, StepDefinition, diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 060efa734..262fdefc1 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -17,8 +17,8 @@ export type UserInput = { type: 'confirmation'; confirmed: boolean }; export interface PendingStepExecution { readonly runId: string; - readonly baseRecord: RecordRef; - readonly currentStep: StepRecord; + readonly baseRecordRef: RecordRef; + readonly step: StepDefinition; readonly previousSteps: ReadonlyArray; readonly userInput?: UserInput; } @@ -27,14 +27,10 @@ export interface StepExecutionResult { stepHistory: StepHistory; } -export interface ExecutionContext< - TStep extends StepDefinition = StepDefinition, - THistory extends StepHistory = StepHistory, -> { +export interface ExecutionContext { readonly runId: string; - readonly baseRecord: RecordRef; + readonly baseRecordRef: RecordRef; readonly step: TStep; - readonly stepHistory: THistory; readonly model: BaseChatModel; readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index b01767671..967fb64bb 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -11,6 +11,7 @@ export enum StepType { interface BaseStepDefinition { id: string; type: StepType; + stepIndex: number; prompt?: string; aiConfigName?: string; } @@ -28,10 +29,4 @@ export interface AiTaskStepDefinition extends BaseStepDefinition { remoteToolsSourceId?: string; } -export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; - -/** - * Coarse categorization of steps. StepType has 5 fine-grained values; - * StepCategory collapses the 4 non-condition types into 'ai-task'. - */ -export type StepCategory = 'condition' | 'ai-task'; +export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; \ No newline at end of file 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 f982cbd7c..6088d6ae3 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -36,6 +36,7 @@ function makeHistoryEntry( step: { id: overrides.stepId ?? 'step-1', type: StepType.Condition, + stepIndex: overrides.stepIndex ?? 0, options: ['A', 'B'], prompt: overrides.prompt ?? 'Pick one', }, @@ -58,7 +59,7 @@ function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', - baseRecord: { + baseRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0, @@ -66,15 +67,10 @@ function makeContext(overrides: Partial = {}): ExecutionContex step: { id: 'step-0', type: StepType.Condition, + stepIndex: 0, options: ['A', 'B'], prompt: 'Pick one', }, - stepHistory: { - type: 'condition', - stepId: 'step-0', - stepIndex: 0, - status: 'success', - }, model: {} as ExecutionContext['model'], agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], @@ -226,7 +222,7 @@ describe('BaseStepExecutor', () => { it('includes status in History for ai-task steps without RunStore data', async () => { const entry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'ai-step', type: StepType.ReadRecord, prompt: 'Run task' }, + step: { id: 'ai-step', type: StepType.ReadRecord, stepIndex: 0, prompt: 'Run task' }, stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'awaiting-input' }, }; @@ -254,7 +250,7 @@ describe('BaseStepExecutor', () => { (condEntry.stepHistory as { selectedOption?: string }).selectedOption = 'Yes'; const aiEntry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'read-customer', type: StepType.ReadRecord, prompt: 'Read name' }, + step: { id: 'read-customer', type: StepType.ReadRecord, stepIndex: 1, prompt: 'Read name' }, stepHistory: { type: 'ai-task', stepId: 'read-customer', stepIndex: 1, status: 'success' }, }; @@ -310,7 +306,7 @@ describe('BaseStepExecutor', () => { it('omits Input line when executionParams is undefined', async () => { const entry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'ai-step', type: StepType.ReadRecord, prompt: 'Do something' }, + step: { id: 'ai-step', type: StepType.ReadRecord, stepIndex: 0, prompt: 'Do something' }, stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'success' }, }; 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 b28c76ed1..771ddd8d0 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -11,22 +11,13 @@ function makeStep(overrides: Partial = {}): ConditionSt return { id: 'cond-1', type: StepType.Condition, + stepIndex: 0, options: ['Approve', 'Reject'], prompt: 'Should we approve this?', ...overrides, }; } -function makeStepHistory(overrides: Partial = {}): ConditionStepHistory { - return { - type: 'condition', - stepId: 'cond-1', - stepIndex: 0, - status: 'success', - ...overrides, - }; -} - function makeMockRunStore(overrides: Partial = {}): RunStore { return { getStepExecutions: jest.fn().mockResolvedValue([]), @@ -48,17 +39,16 @@ function makeMockModel(toolCallArgs?: Record) { } function makeContext( - overrides: Partial> = {}, -): ExecutionContext { + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', - baseRecord: { + baseRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0, } as RecordRef, step: makeStep(), - stepHistory: makeStepHistory(), model: makeMockModel().model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], @@ -70,26 +60,6 @@ function makeContext( } describe('ConditionStepExecutor', () => { - describe('immutability', () => { - it('does not mutate the input stepHistory', async () => { - const mockModel = makeMockModel({ - option: 'Reject', - reasoning: 'Incomplete', - question: 'Approve?', - }); - const stepHistory = makeStepHistory(); - const executor = new ConditionStepExecutor( - makeContext({ model: mockModel.model, stepHistory }), - ); - - const result = await executor.execute(); - - expect(result.stepHistory).not.toBe(stepHistory); - expect(stepHistory.status).toBe('success'); - expect(stepHistory.selectedOption).toBeUndefined(); - }); - }); - describe('AI decision', () => { it('calls AI and returns selected option on success', async () => { const mockModel = makeMockModel({ @@ -210,6 +180,7 @@ describe('ConditionStepExecutor', () => { step: { id: 'prev-step', type: StepType.Condition, + stepIndex: 0, options: ['Yes', 'No'], prompt: 'Previous question', }, @@ -224,8 +195,7 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor({ ...context, - step: makeStep({ id: 'cond-2' }), - stepHistory: makeStepHistory({ stepId: 'cond-2', stepIndex: 1 }), + step: makeStep({ id: 'cond-2', stepIndex: 1 }), }); await executor.execute(); 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 f7ac47f1b..2b73b663d 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 @@ -4,7 +4,6 @@ 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 { AiTaskStepHistory } from '../../src/types/step-history'; import { NoRecordsError, RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; @@ -14,17 +13,8 @@ function makeStep(overrides: Partial = {}): AiTaskStepDefi return { id: 'read-1', type: StepType.ReadRecord, - prompt: 'Read the customer email', - ...overrides, - }; -} - -function makeStepHistory(overrides: Partial = {}): AiTaskStepHistory { - return { - type: 'ai-task', - stepId: 'read-1', stepIndex: 0, - status: 'success', + prompt: 'Read the customer email', ...overrides, }; } @@ -111,13 +101,12 @@ function makeMockModel( } function makeContext( - overrides: Partial> = {}, -): ExecutionContext { + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', - baseRecord: makeRecordRef(), + baseRecordRef: makeRecordRef(), step: makeStep(), - stepHistory: makeStepHistory(), model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), @@ -281,7 +270,7 @@ describe('ReadRecordStepExecutor', () => { describe('multi-record AI selection', () => { it('uses AI to select among multiple records then reads fields', async () => { - const baseRecord = makeRecordRef({ stepIndex: 1 }); + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); const relatedRecord = makeRecordRef({ stepIndex: 2, recordId: [99], @@ -329,7 +318,7 @@ describe('ReadRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ baseRecord, model, runStore, workflowPort }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(); @@ -365,7 +354,7 @@ describe('ReadRecordStepExecutor', () => { }); it('reads fields from the second record when AI selects it', async () => { - const baseRecord = makeRecordRef({ stepIndex: 1 }); + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); const relatedRecord = makeRecordRef({ stepIndex: 2, recordId: [99], @@ -411,7 +400,7 @@ describe('ReadRecordStepExecutor', () => { const agentPort = makeMockAgentPort({ orders: { values: { total: 150 } }, }); - const context = makeContext({ baseRecord, model, runStore, workflowPort, agentPort }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(); @@ -431,7 +420,7 @@ describe('ReadRecordStepExecutor', () => { }); it('includes step index in select-record tool schema when records have stepIndex', async () => { - const baseRecord = makeRecordRef({ stepIndex: 3 }); + const baseRecordRef = makeRecordRef({ stepIndex: 3 }); const relatedRecord = makeRecordRef({ stepIndex: 5, recordId: [99], @@ -475,7 +464,7 @@ describe('ReadRecordStepExecutor', () => { orders: ordersSchema, }); const executor = new ReadRecordStepExecutor( - makeContext({ baseRecord, model, runStore, workflowPort }), + makeContext({ baseRecordRef, model, runStore, workflowPort }), ); await executor.execute(); @@ -492,7 +481,7 @@ describe('ReadRecordStepExecutor', () => { describe('AI record selection failure', () => { it('returns error when AI selects a non-existent record identifier', async () => { - const baseRecord = makeRecordRef(); + const baseRecordRef = makeRecordRef(); const relatedRecord = makeRecordRef({ stepIndex: 1, recordId: [99], @@ -524,7 +513,7 @@ describe('ReadRecordStepExecutor', () => { customers: makeCollectionSchema(), orders: ordersSchema, }); - const context = makeContext({ baseRecord, model, runStore, workflowPort }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); const executor = new ReadRecordStepExecutor(context); const result = await executor.execute(); @@ -646,20 +635,6 @@ describe('ReadRecordStepExecutor', () => { }); }); - describe('immutability', () => { - it('does not mutate the input stepHistory', async () => { - const mockModel = makeMockModel({ fieldNames: ['email'] }); - const stepHistory = makeStepHistory(); - const context = makeContext({ model: mockModel.model, stepHistory }); - const executor = new ReadRecordStepExecutor(context); - - const result = await executor.execute(); - - expect(result.stepHistory).not.toBe(stepHistory); - expect(stepHistory.status).toBe('success'); - }); - }); - describe('previous steps context', () => { it('includes previous steps summary in read-field messages', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); @@ -680,6 +655,7 @@ describe('ReadRecordStepExecutor', () => { step: { id: 'prev-step', type: StepType.Condition, + stepIndex: 0, options: ['Yes', 'No'], prompt: 'Should we proceed?', }, @@ -694,8 +670,7 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor({ ...context, - step: makeStep({ id: 'read-2' }), - stepHistory: makeStepHistory({ stepId: 'read-2', stepIndex: 1 }), + step: makeStep({ id: 'read-2', stepIndex: 1 }), }); await executor.execute(); @@ -733,7 +708,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - stepHistory: makeStepHistory({ stepIndex: 3 }), + step: makeStep({ stepIndex: 3 }), }); const executor = new ReadRecordStepExecutor(context); From 5f46199808ecff8ed30e8c94cc15990cafe84c59 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 09:29:26 +0100 Subject: [PATCH 32/37] refactor(workflow-executor): cache collection schemas and remove unused tool func callbacks Co-Authored-By: Claude Opus 4.6 --- .../src/executors/condition-step-executor.ts | 2 +- .../executors/read-record-step-executor.ts | 22 ++++++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 2c2e183ce..290aa64dc 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -53,7 +53,7 @@ export default class ConditionStepExecutor extends BaseStepExecutor JSON.stringify(input), + func: undefined, }); const messages = [ 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 7ef0c97f3..d772c578c 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -22,6 +22,8 @@ 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 { step } = this.context; const records = await this.getAvailableRecordRefs(); @@ -33,9 +35,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor JSON.stringify(input), + func: undefined, }); const messages = [ @@ -166,7 +166,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor JSON.stringify(input), + func: undefined, }); } @@ -197,8 +197,18 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { + 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.context.workflowPort.getCollectionSchema(record.collectionName); + const schema = await this.getCollectionSchema(record.collectionName); return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; } From 650e70b2061f0cb47fb64b0706b9b7076ea9e1e0 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 10:49:26 +0100 Subject: [PATCH 33/37] refactor(workflow-executor): rename StepHistory to StepOutcome and extract id/stepIndex from StepDefinition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StepHistory → StepOutcome for clarity (it's the outcome of a step, not history). Moved id/stepIndex from StepDefinition (definition concern) to ExecutionContext and PendingStepExecution (instance concern). Renamed StepRecord to Step. Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/CLAUDE.md | 4 +- .../adapters/forest-server-workflow-port.ts | 6 +- .../src/executors/base-step-executor.ts | 20 +++--- .../src/executors/condition-step-executor.ts | 22 +++--- .../executors/read-record-step-executor.ts | 16 ++--- packages/workflow-executor/src/index.ts | 10 +-- .../src/ports/workflow-port.ts | 4 +- .../workflow-executor/src/types/execution.ts | 22 +++--- .../src/types/step-definition.ts | 4 +- .../{step-history.ts => step-outcome.ts} | 10 +-- .../forest-server-workflow-port.test.ts | 10 +-- .../test/executors/base-step-executor.test.ts | 72 ++++++++++++------- .../executors/condition-step-executor.test.ts | 41 ++++++----- .../read-record-step-executor.test.ts | 51 +++++++------ 14 files changed, 157 insertions(+), 135 deletions(-) rename packages/workflow-executor/src/types/{step-history.ts => step-outcome.ts} (77%) diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index c9f799dd9..bff9a8414 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -45,7 +45,7 @@ src/ ├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError ├── types/ # Core type definitions (@draft) │ ├── step-definition.ts # StepType enum + step definition interfaces -│ ├── step-history.ts # Step outcome tracking types +│ ├── step-outcome.ts # Step outcome tracking types (StepOutcome, sent to orchestrator) │ ├── step-execution-data.ts # Runtime state for in-progress steps │ ├── record.ts # Record references and data types │ └── execution.ts # Top-level execution types (context, results) @@ -64,7 +64,7 @@ src/ - **Pull-based** — The executor polls for pending steps via a port interface (`WorkflowPort.getPendingStepExecutions`; polling loop not yet implemented). - **Atomic** — Each step executes in isolation. A run store (scoped per run) maintains continuity between steps. -- **Privacy** — Zero client data leaves the client's infrastructure. `StepHistory` 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). +- **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`. - **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/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 118b62782..16037570b 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -1,7 +1,7 @@ import type { McpConfiguration, WorkflowPort } from '../ports/workflow-port'; import type { PendingStepExecution } from '../types/execution'; import type { CollectionSchema } from '../types/record'; -import type { StepHistory } from '../types/step-history'; +import type { StepOutcome } from '../types/step-outcome'; import type { HttpOptions } from '@forestadmin/forestadmin-client'; import { ServerUtils } from '@forestadmin/forestadmin-client'; @@ -29,13 +29,13 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); } - async updateStepExecution(runId: string, stepHistory: StepHistory): Promise { + async updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise { await ServerUtils.query( this.options, 'post', ROUTES.updateStepExecution(runId), {}, - stepHistory, + stepOutcome, ); } diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 8355f6774..ed9de5cb3 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,7 +1,7 @@ import type { ExecutionContext, StepExecutionResult } from '../types/execution'; import type { StepDefinition } from '../types/step-definition'; import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepHistory } from '../types/step-history'; +import type { StepOutcome } from '../types/step-outcome'; import type { AIMessage, BaseMessage } from '@langchain/core/messages'; import type { DynamicStructuredTool } from '@langchain/core/tools'; @@ -33,29 +33,29 @@ export default abstract class BaseStepExecutor { const allStepExecutions = await this.context.runStore.getStepExecutions(); return this.context.history - .map(({ step, stepHistory }) => { - const execution = allStepExecutions.find(e => e.stepIndex === stepHistory.stepIndex); + .map(({ stepDefinition, stepOutcome }) => { + const execution = allStepExecutions.find(e => e.stepIndex === stepOutcome.stepIndex); - return this.buildStepSummary(step, stepHistory, execution); + return this.buildStepSummary(stepDefinition, stepOutcome, execution); }) .join('\n\n'); } private buildStepSummary( step: StepDefinition, - stepHistory: StepHistory, + stepOutcome: StepOutcome, execution: StepExecutionData | undefined, ): string { const prompt = step.prompt ?? '(no prompt)'; - const header = `Step "${step.id}" (index ${step.stepIndex}):`; + const header = `Step "${stepOutcome.stepId}" (index ${stepOutcome.stepIndex}):`; const lines = [header, ` Prompt: ${prompt}`]; if (isExecutedStepOnExecutor(execution)) { @@ -67,7 +67,7 @@ export default abstract class BaseStepExecutor { async execute(): Promise { - const { step } = this.context; + const { stepDefinition: step } = this.context; const tool = new DynamicStructuredTool({ name: 'choose-gateway-option', @@ -68,10 +68,10 @@ export default class ConditionStepExecutor extends BaseStepExecutor(messages, tool); } catch (error: unknown) { return { - stepHistory: { + stepOutcome: { type: 'condition', - stepId: step.id, - stepIndex: step.stepIndex, + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, status: 'error', error: (error as Error).message, }, @@ -82,27 +82,27 @@ export default class ConditionStepExecutor extends BaseStepExecutor(); async execute(): Promise { - const { step } = this.context; + const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); let selectedRecordRef: RecordRef; @@ -45,10 +45,10 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor; - updateStepExecution(runId: string, stepHistory: StepHistory): Promise; + updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise; getCollectionSchema(collectionName: string): Promise; getMcpServerConfigs(): Promise; } diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 262fdefc1..406d1e4f0 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -2,39 +2,43 @@ import type { RecordRef } from './record'; import type { StepDefinition } from './step-definition'; -import type { StepHistory } from './step-history'; +import type { StepOutcome } from './step-outcome'; import type { AgentPort } from '../ports/agent-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'; -export interface StepRecord { - step: StepDefinition; - stepHistory: StepHistory; +export interface Step { + stepDefinition: StepDefinition; + stepOutcome: StepOutcome; } export type UserInput = { type: 'confirmation'; confirmed: boolean }; export interface PendingStepExecution { readonly runId: string; + readonly stepId: string; + readonly stepIndex: number; readonly baseRecordRef: RecordRef; - readonly step: StepDefinition; - readonly previousSteps: ReadonlyArray; + readonly stepDefinition: StepDefinition; + readonly previousSteps: ReadonlyArray; readonly userInput?: UserInput; } export interface StepExecutionResult { - stepHistory: StepHistory; + stepOutcome: StepOutcome; } export interface ExecutionContext { readonly runId: string; + readonly stepId: string; + readonly stepIndex: number; readonly baseRecordRef: RecordRef; - readonly step: TStep; + readonly stepDefinition: TStep; readonly model: BaseChatModel; readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; readonly runStore: RunStore; - readonly history: ReadonlyArray>; + readonly history: ReadonlyArray>; readonly remoteTools: readonly unknown[]; } diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index 967fb64bb..ca23e5b41 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -9,9 +9,7 @@ export enum StepType { } interface BaseStepDefinition { - id: string; type: StepType; - stepIndex: number; prompt?: string; aiConfigName?: string; } @@ -29,4 +27,4 @@ export interface AiTaskStepDefinition extends BaseStepDefinition { remoteToolsSourceId?: string; } -export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; \ No newline at end of file +export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; diff --git a/packages/workflow-executor/src/types/step-history.ts b/packages/workflow-executor/src/types/step-outcome.ts similarity index 77% rename from packages/workflow-executor/src/types/step-history.ts rename to packages/workflow-executor/src/types/step-outcome.ts index bf9b66b61..9a564748e 100644 --- a/packages/workflow-executor/src/types/step-history.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -12,27 +12,27 @@ export type AiTaskStepStatus = BaseStepStatus | 'awaiting-input'; export type StepStatus = ConditionStepStatus | AiTaskStepStatus; /** - * StepHistory is sent to the orchestrator — it must NEVER contain client data. + * StepOutcome is sent to the orchestrator — it must NEVER contain client data. * Any privacy-sensitive information (e.g. AI reasoning) must stay in * StepExecutionData (persisted in the RunStore, client-side only). */ -interface BaseStepHistory { +interface BaseStepOutcome { stepId: string; stepIndex: number; /** Present when status is 'error'. */ error?: string; } -export interface ConditionStepHistory extends BaseStepHistory { +export interface ConditionStepOutcome extends BaseStepOutcome { type: 'condition'; status: ConditionStepStatus; /** Present when status is 'success'. */ selectedOption?: string; } -export interface AiTaskStepHistory extends BaseStepHistory { +export interface AiTaskStepOutcome extends BaseStepOutcome { type: 'ai-task'; status: AiTaskStepStatus; } -export type StepHistory = ConditionStepHistory | AiTaskStepHistory; +export type StepOutcome = ConditionStepOutcome | AiTaskStepOutcome; 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 37613f3d4..9e69a04ea 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -1,6 +1,6 @@ import type { PendingStepExecution } from '../../src/types/execution'; import type { CollectionSchema } from '../../src/types/record'; -import type { StepHistory } from '../../src/types/step-history'; +import type { StepOutcome } from '../../src/types/step-outcome'; import { ServerUtils } from '@forestadmin/forestadmin-client'; @@ -39,9 +39,9 @@ describe('ForestServerWorkflowPort', () => { }); describe('updateStepExecution', () => { - it('should post step history to the complete route', async () => { + it('should post step outcome to the complete route', async () => { mockQuery.mockResolvedValue(undefined); - const stepHistory: StepHistory = { + const stepOutcome: StepOutcome = { type: 'condition', stepId: 'step-1', stepIndex: 0, @@ -49,14 +49,14 @@ describe('ForestServerWorkflowPort', () => { selectedOption: 'optionA', }; - await port.updateStepExecution('run-42', stepHistory); + await port.updateStepExecution('run-42', stepOutcome); expect(mockQuery).toHaveBeenCalledWith( options, 'post', '/liana/v1/workflow-step-executions/run-42/complete', {}, - stepHistory, + stepOutcome, ); }); }); 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 6088d6ae3..4d79c03cf 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -3,7 +3,7 @@ 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 { StepHistory } from '../../src/types/step-history'; +import type { StepOutcome } from '../../src/types/step-outcome'; import type { BaseMessage, SystemMessage } from '@langchain/core/messages'; import type { DynamicStructuredTool } from '@langchain/core/tools'; @@ -31,16 +31,14 @@ class TestableExecutor extends BaseStepExecutor { function makeHistoryEntry( overrides: { stepId?: string; stepIndex?: number; prompt?: string } = {}, -): { step: StepDefinition; stepHistory: StepHistory } { +): { stepDefinition: StepDefinition; stepOutcome: StepOutcome } { return { - step: { - id: overrides.stepId ?? 'step-1', + stepDefinition: { type: StepType.Condition, - stepIndex: overrides.stepIndex ?? 0, options: ['A', 'B'], prompt: overrides.prompt ?? 'Pick one', }, - stepHistory: { + stepOutcome: { type: 'condition', stepId: overrides.stepId ?? 'step-1', stepIndex: overrides.stepIndex ?? 0, @@ -59,15 +57,15 @@ function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', + stepId: 'step-0', + stepIndex: 0, baseRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0, } as RecordRef, - step: { - id: 'step-0', + stepDefinition: { type: StepType.Condition, - stepIndex: 0, options: ['A', 'B'], prompt: 'Pick one', }, @@ -179,7 +177,7 @@ describe('BaseStepExecutor', () => { stepIndex: 0, prompt: 'Approved?', }); - (entry.stepHistory as { selectedOption?: string }).selectedOption = 'Yes'; + (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; const executor = new TestableExecutor( makeContext({ @@ -202,8 +200,8 @@ describe('BaseStepExecutor', () => { stepIndex: 0, prompt: 'Do something', }); - entry.stepHistory.status = 'error'; - (entry.stepHistory as { error?: string }).error = 'AI could not match an option'; + entry.stepOutcome.status = 'error'; + (entry.stepOutcome as { error?: string }).error = 'AI could not match an option'; const executor = new TestableExecutor( makeContext({ @@ -221,9 +219,17 @@ describe('BaseStepExecutor', () => { }); it('includes status in History for ai-task steps without RunStore data', async () => { - const entry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'ai-step', type: StepType.ReadRecord, stepIndex: 0, prompt: 'Run task' }, - stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'awaiting-input' }, + const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { + stepDefinition: { + type: StepType.ReadRecord, + prompt: 'Run task', + }, + stepOutcome: { + type: 'ai-task', + stepId: 'ai-step', + stepIndex: 0, + status: 'awaiting-input', + }, }; const executor = new TestableExecutor( @@ -247,11 +253,19 @@ describe('BaseStepExecutor', () => { stepIndex: 0, prompt: 'Approved?', }); - (condEntry.stepHistory as { selectedOption?: string }).selectedOption = 'Yes'; - - const aiEntry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'read-customer', type: StepType.ReadRecord, stepIndex: 1, prompt: 'Read name' }, - stepHistory: { type: 'ai-task', stepId: 'read-customer', stepIndex: 1, status: 'success' }, + (condEntry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; + + const aiEntry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { + stepDefinition: { + type: StepType.ReadRecord, + prompt: 'Read name', + }, + stepOutcome: { + type: 'ai-task', + stepId: 'read-customer', + stepIndex: 1, + status: 'success', + }, }; const executor = new TestableExecutor( @@ -279,7 +293,7 @@ describe('BaseStepExecutor', () => { it('prefers RunStore execution data over History fallback', async () => { const entry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Pick one' }); - (entry.stepHistory as { selectedOption?: string }).selectedOption = 'A'; + (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'A'; const executor = new TestableExecutor( makeContext({ @@ -305,9 +319,17 @@ describe('BaseStepExecutor', () => { }); it('omits Input line when executionParams is undefined', async () => { - const entry: { step: StepDefinition; stepHistory: StepHistory } = { - step: { id: 'ai-step', type: StepType.ReadRecord, stepIndex: 0, prompt: 'Do something' }, - stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'success' }, + const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { + stepDefinition: { + type: StepType.ReadRecord, + prompt: 'Do something', + }, + stepOutcome: { + type: 'ai-task', + stepId: 'ai-step', + stepIndex: 0, + status: 'success', + }, }; const executor = new TestableExecutor( @@ -333,7 +355,7 @@ describe('BaseStepExecutor', () => { it('shows "(no prompt)" when step has no prompt', async () => { const entry = makeHistoryEntry({ stepIndex: 0 }); - entry.step.prompt = undefined; + entry.stepDefinition.prompt = undefined; const executor = new TestableExecutor( makeContext({ 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 771ddd8d0..b41cf35bc 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -2,16 +2,14 @@ import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext } from '../../src/types/execution'; import type { RecordRef } from '../../src/types/record'; import type { ConditionStepDefinition } from '../../src/types/step-definition'; -import type { ConditionStepHistory } from '../../src/types/step-history'; +import type { ConditionStepOutcome } from '../../src/types/step-outcome'; import ConditionStepExecutor from '../../src/executors/condition-step-executor'; import { StepType } from '../../src/types/step-definition'; function makeStep(overrides: Partial = {}): ConditionStepDefinition { return { - id: 'cond-1', type: StepType.Condition, - stepIndex: 0, options: ['Approve', 'Reject'], prompt: 'Should we approve this?', ...overrides, @@ -43,12 +41,14 @@ function makeContext( ): ExecutionContext { return { runId: 'run-1', + stepId: 'cond-1', + stepIndex: 0, baseRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0, } as RecordRef, - step: makeStep(), + stepDefinition: makeStep(), model: makeMockModel().model, agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], @@ -76,8 +76,8 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); - expect((result.stepHistory as ConditionStepHistory).selectedOption).toBe('Reject'); + expect(result.stepOutcome.status).toBe('success'); + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBe('Reject'); expect(mockModel.bindTools).toHaveBeenCalledWith( [expect.objectContaining({ name: 'choose-gateway-option' })], @@ -101,7 +101,7 @@ describe('ConditionStepExecutor', () => { const executor = new ConditionStepExecutor( makeContext({ model: mockModel.model, - step: makeStep({ options: ['Approve', 'Reject', 'Defer'] }), + stepDefinition: makeStep({ options: ['Approve', 'Reject', 'Defer'] }), }), ); @@ -125,7 +125,7 @@ describe('ConditionStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - step: makeStep({ prompt: 'Custom prompt for this step' }), + stepDefinition: makeStep({ prompt: 'Custom prompt for this step' }), }); const executor = new ConditionStepExecutor(context); @@ -146,7 +146,7 @@ describe('ConditionStepExecutor', () => { }); const context = makeContext({ model: mockModel.model, - step: makeStep({ prompt: undefined }), + stepDefinition: makeStep({ prompt: undefined }), }); const executor = new ConditionStepExecutor(context); @@ -177,14 +177,12 @@ describe('ConditionStepExecutor', () => { runStore, history: [ { - step: { - id: 'prev-step', + stepDefinition: { type: StepType.Condition, - stepIndex: 0, options: ['Yes', 'No'], prompt: 'Previous question', }, - stepHistory: { + stepOutcome: { type: 'condition', stepId: 'prev-step', stepIndex: 0, @@ -195,7 +193,8 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor({ ...context, - step: makeStep({ id: 'cond-2', stepIndex: 1 }), + stepId: 'cond-2', + stepIndex: 1, }); await executor.execute(); @@ -225,9 +224,9 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('manual-decision'); - expect(result.stepHistory.error).toBeUndefined(); - expect((result.stepHistory as ConditionStepHistory).selectedOption).toBeUndefined(); + expect(result.stepOutcome.status).toBe('manual-decision'); + expect(result.stepOutcome.error).toBeUndefined(); + expect((result.stepOutcome as ConditionStepOutcome).selectedOption).toBeUndefined(); expect(runStore.saveStepExecution).toHaveBeenCalledWith({ type: 'condition', stepIndex: 0, @@ -253,8 +252,8 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe( + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( 'AI returned a malformed tool call for "choose-gateway-option": JSON parse error', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -272,8 +271,8 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe('API timeout'); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('API timeout'); }); it('lets run store errors propagate', async () => { 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 2b73b663d..779842a29 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 @@ -11,9 +11,7 @@ import { StepType } from '../../src/types/step-definition'; function makeStep(overrides: Partial = {}): AiTaskStepDefinition { return { - id: 'read-1', type: StepType.ReadRecord, - stepIndex: 0, prompt: 'Read the customer email', ...overrides, }; @@ -105,8 +103,10 @@ function makeContext( ): ExecutionContext { return { runId: 'run-1', + stepId: 'read-1', + stepIndex: 0, baseRecordRef: makeRecordRef(), - step: makeStep(), + stepDefinition: makeStep(), model: makeMockModel({ fieldNames: ['email'] }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), @@ -127,7 +127,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ type: 'read-record', @@ -150,7 +150,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionParams: { fieldNames: ['email', 'name'] }, @@ -174,7 +174,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionResult: { @@ -194,7 +194,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionResult: { @@ -260,8 +260,8 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe( + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( 'No readable fields on record from collection "customers"', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -323,7 +323,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(bindTools).toHaveBeenCalledTimes(2); // First call: select-record tool @@ -405,7 +405,7 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('success'); + expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ executionResult: { @@ -518,8 +518,8 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe( + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( 'AI selected record "NonExistent #999" which does not match any available record', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -539,8 +539,8 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe('Record not found: collection "customers", id "42"'); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Record not found: collection "customers", id "42"'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -586,8 +586,8 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe( + 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', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -605,8 +605,8 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepHistory.status).toBe('error'); - expect(result.stepHistory.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('AI did not return a tool call'); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); @@ -652,14 +652,12 @@ describe('ReadRecordStepExecutor', () => { runStore, history: [ { - step: { - id: 'prev-step', + stepDefinition: { type: StepType.Condition, - stepIndex: 0, options: ['Yes', 'No'], prompt: 'Should we proceed?', }, - stepHistory: { + stepOutcome: { type: 'condition', stepId: 'prev-step', stepIndex: 0, @@ -670,7 +668,8 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor({ ...context, - step: makeStep({ id: 'read-2', stepIndex: 1 }), + stepId: 'read-2', + stepIndex: 1, }); await executor.execute(); @@ -689,7 +688,7 @@ describe('ReadRecordStepExecutor', () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, - step: makeStep({ prompt: undefined }), + stepDefinition: makeStep({ prompt: undefined }), }); const executor = new ReadRecordStepExecutor(context); @@ -708,7 +707,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - step: makeStep({ stepIndex: 3 }), + stepIndex: 3, }); const executor = new ReadRecordStepExecutor(context); From 8f638c0b99fa2c14a837dd29bf412bc388b0289a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 15:11:07 +0100 Subject: [PATCH 34/37] feat(workflow-executor): add optional fieldNames to AgentPort.getRecord Resolve AI display names to real field names before calling getRecord, so only the needed fields are fetched from the datasource. Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/agent-client-agent-port.ts | 3 +- .../executors/read-record-step-executor.ts | 36 ++++++++++------- .../workflow-executor/src/ports/agent-port.ts | 6 ++- .../adapters/agent-client-agent-port.test.ts | 34 ++++++++++++++++ .../read-record-step-executor.test.ts | 39 +++++++++++++++++++ 5 files changed, 103 insertions(+), 15 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 5f9382c5e..cf8949a1a 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -46,11 +46,12 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionSchemas = params.collectionSchemas; } - async getRecord(collectionName: string, recordId: Array) { + 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), pagination: { size: 1, number: 1 }, + ...(fieldNames?.length && { fields: fieldNames }), }); if (records.length === 0) { 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 fd25a8177..3ab85365c 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -3,6 +3,7 @@ import type { CollectionSchema, RecordRef } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult, + FieldReadSuccess, LoadRelatedRecordStepExecutionData, } from '../types/step-execution-data'; @@ -30,18 +31,22 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor; + let fieldResults: FieldReadResult[]; try { selectedRecordRef = await this.selectRecordRef(records, step.prompt); schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - fieldNames = await this.selectFields(schema, step.prompt); + const selectedDisplayNames = await this.selectFields(schema, step.prompt); + const resolvedFields = this.resolveFieldNames(schema, selectedDisplayNames); + const resolvedFieldNames = resolvedFields + .filter((f): f is FieldReadSuccess => !('error' in f)) + .map(f => f.fieldName); const recordData = await this.context.agentPort.getRecord( selectedRecordRef.collectionName, selectedRecordRef.recordId, + resolvedFieldNames.length > 0 ? resolvedFieldNames : undefined, ); - values = recordData.values; + fieldResults = this.formatFieldResults(recordData.values, resolvedFields); } catch (error) { if (error instanceof WorkflowExecutorError) { return { @@ -58,12 +63,10 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor f.fieldName) }, executionResult: { fields: fieldResults }, selectedRecordRef, }); @@ -170,16 +173,23 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor, - schema: CollectionSchema, - fieldNames: string[], - ): FieldReadResult[] { - return fieldNames.map(name => { + private resolveFieldNames(schema: CollectionSchema, displayNames: string[]): FieldReadResult[] { + return displayNames.map(name => { const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; + return { value: undefined, fieldName: field.fieldName, displayName: field.displayName }; + }); + } + + private formatFieldResults( + values: Record, + resolvedFields: FieldReadResult[], + ): FieldReadResult[] { + return resolvedFields.map(field => { + if ('error' in field) return field; + return { value: values[field.fieldName], fieldName: field.fieldName, diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 8a05c918d..a0964e250 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -3,7 +3,11 @@ import type { RecordData } from '../types/record'; export interface AgentPort { - getRecord(collectionName: string, recordId: Array): Promise; + getRecord( + collectionName: string, + recordId: Array, + fieldNames?: string[], + ): Promise; updateRecord( collectionName: string, recordId: Array, 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 e39cb8a6f..b564eeaf5 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 @@ -113,6 +113,40 @@ describe('AgentClientAgentPort', () => { await expect(port.getRecord('users', [999])).rejects.toThrow(RecordNotFoundError); }); + it('should pass fields to list when fieldNames is provided', async () => { + mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); + + await port.getRecord('users', [42], ['id', 'name']); + + expect(mockCollection.list).toHaveBeenCalledWith({ + filters: { field: 'id', operator: 'Equal', value: 42 }, + pagination: { size: 1, number: 1 }, + fields: ['id', 'name'], + }); + }); + + it('should not pass fields to list when fieldNames is an empty array', async () => { + mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); + + await port.getRecord('users', [42], []); + + expect(mockCollection.list).toHaveBeenCalledWith({ + filters: { field: 'id', operator: 'Equal', value: 42 }, + pagination: { size: 1, number: 1 }, + }); + }); + + it('should not pass fields to list when fieldNames is undefined', async () => { + mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); + + await port.getRecord('users', [42]); + + expect(mockCollection.list).toHaveBeenCalledWith({ + filters: { field: 'id', operator: 'Equal', value: 42 }, + pagination: { size: 1, number: 1 }, + }); + }); + it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: 1 }]); 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 779842a29..b2bb1ee8e 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 @@ -177,6 +177,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( expect.objectContaining({ + executionParams: { fieldNames: ['name'] }, executionResult: { fields: [{ value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }], }, @@ -185,6 +186,44 @@ 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 agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(); + + expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['name', 'email']); + }); + + it('passes only resolved field names when some fields are unresolved', async () => { + const mockModel = makeMockModel({ fieldNames: ['Email', 'nonexistent'] }); + const agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(); + + expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['email']); + }); + + it('omits fieldNames from getRecord when all fields are unresolved', async () => { + const mockModel = makeMockModel({ fieldNames: ['nonexistent'] }); + const agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new ReadRecordStepExecutor(context); + + await executor.execute(); + + expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], undefined); + }); + }); + describe('field not found', () => { it('returns error per field without failing globally', async () => { const mockModel = makeMockModel({ fieldNames: ['email', 'nonexistent'] }); From 761f08088b445f0cc2efaabaa92a1465fa953e13 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 15:19:17 +0100 Subject: [PATCH 35/37] refactor(workflow-executor): skip getRecord when no fields resolve Co-Authored-By: Claude Opus 4.6 --- .../src/executors/read-record-step-executor.ts | 17 +++++++++++------ .../executors/read-record-step-executor.test.ts | 4 ++-- 2 files changed, 13 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 3ab85365c..f2355d20b 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -41,12 +41,17 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor !('error' in f)) .map(f => f.fieldName); - const recordData = await this.context.agentPort.getRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - resolvedFieldNames.length > 0 ? resolvedFieldNames : undefined, - ); - fieldResults = this.formatFieldResults(recordData.values, resolvedFields); + + if (resolvedFieldNames.length > 0) { + const recordData = await this.context.agentPort.getRecord( + selectedRecordRef.collectionName, + selectedRecordRef.recordId, + resolvedFieldNames, + ); + fieldResults = this.formatFieldResults(recordData.values, resolvedFields); + } else { + fieldResults = resolvedFields as FieldReadResult[]; + } } catch (error) { if (error instanceof WorkflowExecutorError) { return { 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 b2bb1ee8e..5994e1f69 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 @@ -211,7 +211,7 @@ describe('ReadRecordStepExecutor', () => { expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['email']); }); - it('omits fieldNames from getRecord when all fields are unresolved', async () => { + it('skips getRecord when all fields are unresolved', async () => { const mockModel = makeMockModel({ fieldNames: ['nonexistent'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); @@ -220,7 +220,7 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(); - expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], undefined); + expect(agentPort.getRecord).not.toHaveBeenCalled(); }); }); From 9e6b56b724ffea1959bfa11781bbb2963f740e77 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 15:22:01 +0100 Subject: [PATCH 36/37] feat(workflow-executor): return step error when no fields can be resolved Co-Authored-By: Claude Opus 4.6 --- packages/workflow-executor/src/errors.ts | 6 +++++ .../executors/read-record-step-executor.ts | 25 +++++++++++-------- packages/workflow-executor/src/index.ts | 1 + .../read-record-step-executor.test.ts | 11 +++++--- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 883b36e4b..b835c391f 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -39,3 +39,9 @@ export class NoReadableFieldsError extends WorkflowExecutorError { super(`No readable fields on record from collection "${collectionName}"`); } } + +export class NoResolvedFieldsError extends WorkflowExecutorError { + constructor(fieldNames: string[]) { + super(`None of the requested fields could be resolved: ${fieldNames.join(', ')}`); + } +} 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 f2355d20b..a086f47fa 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -11,7 +11,12 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages'; import { DynamicStructuredTool } from '@langchain/core/tools'; import { z } from 'zod'; -import { NoReadableFieldsError, NoRecordsError, WorkflowExecutorError } from '../errors'; +import { + NoReadableFieldsError, + NoRecordsError, + 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. @@ -42,16 +47,16 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor !('error' in f)) .map(f => f.fieldName); - if (resolvedFieldNames.length > 0) { - const recordData = await this.context.agentPort.getRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - resolvedFieldNames, - ); - fieldResults = this.formatFieldResults(recordData.values, resolvedFields); - } else { - fieldResults = resolvedFields as FieldReadResult[]; + 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, resolvedFields); } catch (error) { if (error instanceof WorkflowExecutorError) { return { diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 75f26bd99..16c054cfd 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -53,6 +53,7 @@ export { RecordNotFoundError, NoRecordsError, NoReadableFieldsError, + NoResolvedFieldsError, } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; 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 5994e1f69..743560066 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 @@ -211,16 +211,21 @@ describe('ReadRecordStepExecutor', () => { expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['email']); }); - it('skips getRecord when all fields are unresolved', async () => { - const mockModel = makeMockModel({ fieldNames: ['nonexistent'] }); + it('returns error when no fields can be resolved', async () => { + const mockModel = makeMockModel({ fieldNames: ['nonexistent', 'unknown'] }); const agentPort = makeMockAgentPort(); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); const executor = new ReadRecordStepExecutor(context); - await executor.execute(); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'None of the requested fields could be resolved: nonexistent, unknown', + ); expect(agentPort.getRecord).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); From 6dac96900b2dd9623379c5b30bb86a26b7e83354 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 19 Mar 2026 15:26:47 +0100 Subject: [PATCH 37/37] refactor(workflow-executor): remove resolveFieldNames, inline field name extraction Co-Authored-By: Claude Opus 4.6 --- .../executors/read-record-step-executor.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 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 a086f47fa..e164e1690 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -3,7 +3,6 @@ import type { CollectionSchema, RecordRef } from '../types/record'; import type { AiTaskStepDefinition } from '../types/step-definition'; import type { FieldReadResult, - FieldReadSuccess, LoadRelatedRecordStepExecutionData, } from '../types/step-execution-data'; @@ -42,10 +41,12 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor !('error' in f)) - .map(f => f.fieldName); + const resolvedFieldNames = selectedDisplayNames + .map( + name => + schema.fields.find(f => f.fieldName === name || f.displayName === name)?.fieldName, + ) + .filter((name): name is string => name !== undefined); if (resolvedFieldNames.length === 0) { throw new NoResolvedFieldsError(selectedDisplayNames); @@ -56,7 +57,7 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); - - if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; - - return { value: undefined, fieldName: field.fieldName, displayName: field.displayName }; - }); - } - private formatFieldResults( values: Record, - resolvedFields: FieldReadResult[], + schema: CollectionSchema, + fieldNames: string[], ): FieldReadResult[] { - return resolvedFields.map(field => { - if ('error' in field) return field; + return fieldNames.map(name => { + const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); + + if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; return { value: values[field.fieldName],