From 8bf3638113592ac9a5a12b5a9375ad3e3775747e Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 10:57:52 +0100 Subject: [PATCH 01/12] feat(workflow-executor): implement AgentPort adapter using agent-client Add AgentClientAgentPort class that wraps @forestadmin/agent-client's RemoteAgentClient to satisfy the AgentPort interface. Includes capabilities caching with rejection eviction, and full test coverage. fixes PRD-232 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/workflow-executor/package.json | 3 + .../src/adapters/agent-client-agent-port.ts | 122 +++++++++ packages/workflow-executor/src/index.ts | 1 + .../adapters/agent-client-agent-port.test.ts | 233 ++++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 packages/workflow-executor/src/adapters/agent-client-agent-port.ts create mode 100644 packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index 3c838da93..d2579c1e3 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -15,6 +15,9 @@ "dist/**/*.js", "dist/**/*.d.ts" ], + "dependencies": { + "@forestadmin/agent-client": "1.4.13" + }, "scripts": { "build": "tsc", "build:watch": "tsc --watch", diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts new file mode 100644 index 000000000..736114bd7 --- /dev/null +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -0,0 +1,122 @@ +import type { AgentPort } from '../ports/agent-port'; +import type { RecordData, RecordFieldRef } from '../types/record'; +import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; + +function toRecordFieldRef(field: { name: string; type: string }): RecordFieldRef { + return { + fieldName: field.name, + displayName: field.name, + type: field.type, + isRelationship: field.type === 'ManyToOne', + referencedCollectionName: undefined, + }; +} + +export default class AgentClientAgentPort implements AgentPort { + private readonly client: RemoteAgentClient; + private readonly actionEndpoints: ActionEndpointsByCollection; + private readonly capabilitiesCache = new Map< + string, + Promise<{ fields: { name: string; type: string; operators: string[] }[] }> + >(); + + constructor(params: { client: RemoteAgentClient; actionEndpoints: ActionEndpointsByCollection }) { + this.client = params.client; + this.actionEndpoints = params.actionEndpoints; + } + + async getRecord(collectionName: string, recordId: string): Promise { + const records = await this.client.collection(collectionName).list>({ + filters: { field: 'id', operator: 'Equal', value: recordId }, + pagination: { size: 1, number: 1 }, + }); + + if (records.length === 0) { + throw new Error(`Record not found: collection "${collectionName}", id "${recordId}"`); + } + + const capabilities = await this.getCapabilities(collectionName); + + return { + recordId, + collectionName, + collectionDisplayName: collectionName, + fields: capabilities.fields.map(toRecordFieldRef), + values: records[0], + }; + } + + async updateRecord( + collectionName: string, + recordId: string, + values: Record, + ): Promise { + const updatedRecord = await this.client + .collection(collectionName) + .update>(recordId, values); + + const capabilities = await this.getCapabilities(collectionName); + + return { + recordId, + collectionName, + collectionDisplayName: collectionName, + fields: capabilities.fields.map(toRecordFieldRef), + values: updatedRecord, + }; + } + + async getRelatedData( + collectionName: string, + recordId: string, + relationName: string, + ): Promise { + const records = await this.client + .collection(collectionName) + .relation(relationName, recordId) + .list>(); + + return records.map(record => ({ + recordId: String(record.id ?? ''), + collectionName: relationName, + collectionDisplayName: relationName, + fields: [], + values: record, + })); + } + + async getActions(collectionName: string): Promise { + const endpoints = this.actionEndpoints[collectionName]; + + return endpoints ? Object.keys(endpoints) : []; + } + + async executeAction( + collectionName: string, + actionName: string, + recordIds: string[], + ): Promise { + const action = await this.client.collection(collectionName).action(actionName, { recordIds }); + + return action.execute(); + } + + private getCapabilities( + collectionName: string, + ): Promise<{ fields: { name: string; type: string; operators: string[] }[] }> { + let cached = this.capabilitiesCache.get(collectionName); + + if (!cached) { + cached = this.client + .collection(collectionName) + .capabilities() + .catch(error => { + this.capabilitiesCache.delete(collectionName); + throw error; + }); + this.capabilitiesCache.set(collectionName, cached); + } + + return cached; + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 9d570f572..ef2ccdee4 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -36,3 +36,4 @@ export type { RunStore } from './ports/run-store'; export { WorkflowExecutorError, MissingToolCallError, MalformedToolCallError } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; +export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port'; 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 new file mode 100644 index 000000000..ecff9e032 --- /dev/null +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -0,0 +1,233 @@ +import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; + +import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; + +function createMockClient() { + const mockAction = { execute: jest.fn() }; + const mockRelation = { list: jest.fn() }; + const mockCollection = { + list: jest.fn(), + update: jest.fn(), + capabilities: jest.fn(), + relation: jest.fn().mockReturnValue(mockRelation), + action: jest.fn().mockResolvedValue(mockAction), + }; + + const client = { + collection: jest.fn().mockReturnValue(mockCollection), + } as unknown as jest.Mocked; + + return { client, mockCollection, mockRelation, mockAction }; +} + +describe('AgentClientAgentPort', () => { + let client: jest.Mocked; + let mockCollection: ReturnType['mockCollection']; + let mockRelation: ReturnType['mockRelation']; + let mockAction: ReturnType['mockAction']; + let actionEndpoints: ActionEndpointsByCollection; + let port: AgentClientAgentPort; + + beforeEach(() => { + jest.clearAllMocks(); + + ({ client, mockCollection, mockRelation, mockAction } = createMockClient()); + + actionEndpoints = { + users: { + sendEmail: { name: 'Send Email', endpoint: '/forest/actions/send-email' }, + archive: { name: 'Archive', endpoint: '/forest/actions/archive' }, + }, + }; + + port = new AgentClientAgentPort({ client, actionEndpoints }); + }); + + describe('getRecord', () => { + it('should return a RecordData when the record exists', async () => { + mockCollection.list.mockResolvedValue([{ id: '42', name: 'Alice' }]); + mockCollection.capabilities.mockResolvedValue({ + fields: [ + { name: 'id', type: 'Number', operators: ['equal'] }, + { name: 'name', type: 'String', operators: ['equal', 'contains'] }, + ], + }); + + const result = await port.getRecord('users', '42'); + + expect(client.collection).toHaveBeenCalledWith('users'); + expect(mockCollection.list).toHaveBeenCalledWith({ + filters: { field: 'id', operator: 'Equal', value: '42' }, + pagination: { size: 1, number: 1 }, + }); + expect(result).toEqual({ + recordId: '42', + collectionName: 'users', + collectionDisplayName: 'users', + fields: [ + { + fieldName: 'id', + displayName: 'id', + type: 'Number', + isRelationship: false, + referencedCollectionName: undefined, + }, + { + fieldName: 'name', + displayName: 'name', + type: 'String', + isRelationship: false, + referencedCollectionName: undefined, + }, + ], + values: { id: '42', name: 'Alice' }, + }); + }); + + it('should throw when no record is found', async () => { + mockCollection.list.mockResolvedValue([]); + + await expect(port.getRecord('users', '999')).rejects.toThrow( + 'Record not found: collection "users", id "999"', + ); + }); + + it('should cache capabilities between calls on the same collection', async () => { + mockCollection.list.mockResolvedValue([{ id: '1' }]); + mockCollection.capabilities.mockResolvedValue({ fields: [] }); + + await port.getRecord('users', '1'); + await port.getRecord('users', '2'); + + expect(mockCollection.capabilities).toHaveBeenCalledTimes(1); + }); + + it('should evict cache and retry when capabilities rejects', async () => { + mockCollection.list.mockResolvedValue([{ id: '1' }]); + mockCollection.capabilities + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ fields: [{ name: 'id', type: 'Number', operators: [] }] }); + + await expect(port.getRecord('users', '1')).rejects.toThrow('Network error'); + + const result = await port.getRecord('users', '1'); + + expect(mockCollection.capabilities).toHaveBeenCalledTimes(2); + expect(result.fields).toEqual([expect.objectContaining({ fieldName: 'id', type: 'Number' })]); + }); + + it('should map ManyToOne fields as relationships', async () => { + mockCollection.list.mockResolvedValue([{ id: '1', company: 5 }]); + mockCollection.capabilities.mockResolvedValue({ + fields: [{ name: 'company', type: 'ManyToOne', operators: [] }], + }); + + const result = await port.getRecord('users', '1'); + + expect(result.fields[0]).toEqual( + expect.objectContaining({ isRelationship: true, fieldName: 'company' }), + ); + }); + }); + + describe('updateRecord', () => { + it('should return a RecordData after update', async () => { + mockCollection.update.mockResolvedValue({ id: '42', name: 'Bob' }); + mockCollection.capabilities.mockResolvedValue({ + fields: [{ name: 'name', type: 'String', operators: [] }], + }); + + const result = await port.updateRecord('users', '42', { name: 'Bob' }); + + expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); + expect(result).toEqual({ + recordId: '42', + collectionName: 'users', + collectionDisplayName: 'users', + fields: [ + { + fieldName: 'name', + displayName: 'name', + type: 'String', + isRelationship: false, + referencedCollectionName: undefined, + }, + ], + values: { id: '42', name: 'Bob' }, + }); + }); + }); + + describe('getRelatedData', () => { + it('should return a RecordData array', async () => { + mockRelation.list.mockResolvedValue([ + { id: '10', title: 'Post A' }, + { id: '11', title: 'Post B' }, + ]); + + const result = await port.getRelatedData('users', '42', 'posts'); + + expect(client.collection).toHaveBeenCalledWith('users'); + expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); + expect(result).toEqual([ + { + recordId: '10', + collectionName: 'posts', + collectionDisplayName: 'posts', + fields: [], + values: { id: '10', title: 'Post A' }, + }, + { + recordId: '11', + collectionName: 'posts', + collectionDisplayName: 'posts', + fields: [], + values: { id: '11', title: 'Post B' }, + }, + ]); + }); + + it('should return an empty array when no related data exists', async () => { + mockRelation.list.mockResolvedValue([]); + + const result = await port.getRelatedData('users', '42', 'posts'); + + expect(result).toEqual([]); + }); + }); + + describe('getActions', () => { + it('should return action names from actionEndpoints', async () => { + const result = await port.getActions('users'); + + expect(result).toEqual(['sendEmail', 'archive']); + }); + + it('should return an empty array for an unknown collection', async () => { + const result = await port.getActions('unknown'); + + expect(result).toEqual([]); + }); + }); + + describe('executeAction', () => { + it('should call action then execute with the correct recordIds', async () => { + mockAction.execute.mockResolvedValue({ success: 'Email sent' }); + + const result = await port.executeAction('users', 'sendEmail', ['1', '2']); + + expect(client.collection).toHaveBeenCalledWith('users'); + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); + expect(mockAction.execute).toHaveBeenCalledTimes(1); + expect(result).toEqual({ success: 'Email sent' }); + }); + + it('should propagate errors from action execution', async () => { + mockAction.execute.mockRejectedValue(new Error('Action failed')); + + await expect(port.executeAction('users', 'sendEmail', ['1'])).rejects.toThrow( + 'Action failed', + ); + }); + }); +}); From 81f38bd3545384e95697ba8d85f54c439e5ebcf1 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 11:40:27 +0100 Subject: [PATCH 02/12] refactor(workflow-executor): replace RecordRef with CollectionRef Split RecordRef into CollectionRef (collection-level info: name, displayName, fields) and move recordId into RecordData. This fixes the type inconsistency where getCollectionRef() returned a RecordRef that had no meaningful recordId. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/workflow-executor/src/index.ts | 2 +- packages/workflow-executor/src/ports/workflow-port.ts | 4 ++-- packages/workflow-executor/src/types/record.ts | 6 +++--- packages/workflow-executor/src/types/step-execution-data.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index ef2ccdee4..9c0769bca 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -19,7 +19,7 @@ export type { StepExecutionData, } from './types/step-execution-data'; -export type { RecordFieldRef, RecordRef, RecordData } from './types/record'; +export type { RecordFieldRef, CollectionRef, RecordData } from './types/record'; export type { StepRecord, diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index 806bb3398..c36ea41d8 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 { RecordRef } from '../types/record'; +import type { CollectionRef } 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. */ @@ -10,6 +10,6 @@ export type McpConfiguration = unknown; export interface WorkflowPort { getPendingStepExecutions(): Promise; completeStepExecution(runId: string, stepHistory: StepHistory): Promise; - getCollectionRef(collectionName: string): Promise; + getCollectionRef(collectionName: string): Promise; getMcpServerConfigs(): Promise; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 9610da056..de65788cd 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -8,13 +8,13 @@ export interface RecordFieldRef { referencedCollectionName?: string; } -export interface RecordRef { - recordId: string; +export interface CollectionRef { collectionName: string; collectionDisplayName: string; fields: RecordFieldRef[]; } -export interface RecordData extends RecordRef { +export interface RecordData extends CollectionRef { + recordId: string; 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 5b5549c87..46bd0f981 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 } from './record'; interface BaseStepExecutionData { stepIndex: number; @@ -17,7 +17,7 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { executionParams?: Record; executionResult?: Record; toolConfirmationInterruption?: Record; - selectedRecordRef?: RecordRef; + selectedRecord?: RecordData; } export type StepExecutionData = ConditionStepExecutionData | AiTaskStepExecutionData; From 1acc164949c69c8da798e443241dddeb52ee318b Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 11:46:34 +0100 Subject: [PATCH 03/12] =?UTF-8?q?fix(workflow-executor):=20address=20revie?= =?UTF-8?q?w=20=E2=80=94=20dedicated=20error=20class,=20all=20relationship?= =?UTF-8?q?=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create RecordNotFoundError in src/errors.ts for typed error handling - Check all 4 relationship types (ManyToOne, OneToOne, OneToMany, ManyToMany) using a RELATIONSHIP_TYPES Set instead of hardcoded ManyToOne check - Add parameterized tests for each relationship type Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 8 ++++-- packages/workflow-executor/src/errors.ts | 6 +++++ packages/workflow-executor/src/index.ts | 7 ++++- .../adapters/agent-client-agent-port.test.ts | 27 +++++++++++-------- 4 files changed, 34 insertions(+), 14 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 736114bd7..8f76452c8 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -2,12 +2,16 @@ import type { AgentPort } from '../ports/agent-port'; import type { RecordData, RecordFieldRef } from '../types/record'; import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; +import { RecordNotFoundError } from '../errors'; + +const RELATIONSHIP_TYPES = new Set(['ManyToOne', 'OneToOne', 'OneToMany', 'ManyToMany']); + function toRecordFieldRef(field: { name: string; type: string }): RecordFieldRef { return { fieldName: field.name, displayName: field.name, type: field.type, - isRelationship: field.type === 'ManyToOne', + isRelationship: RELATIONSHIP_TYPES.has(field.type), referencedCollectionName: undefined, }; } @@ -32,7 +36,7 @@ export default class AgentClientAgentPort implements AgentPort { }); if (records.length === 0) { - throw new Error(`Record not found: collection "${collectionName}", id "${recordId}"`); + throw new RecordNotFoundError(collectionName, recordId); } const capabilities = await this.getCapabilities(collectionName); diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 3a853e949..d735977d4 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -21,3 +21,9 @@ export class MalformedToolCallError extends WorkflowExecutorError { this.toolName = toolName; } } + +export class RecordNotFoundError extends WorkflowExecutorError { + constructor(collectionName: string, recordId: string) { + super(`Record not found: collection "${collectionName}", id "${recordId}"`); + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 9c0769bca..a6b485aed 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -33,7 +33,12 @@ export type { AgentPort } from './ports/agent-port'; export type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; export type { RunStore } from './ports/run-store'; -export { WorkflowExecutorError, MissingToolCallError, MalformedToolCallError } from './errors'; +export { + WorkflowExecutorError, + MissingToolCallError, + MalformedToolCallError, + RecordNotFoundError, +} from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port'; 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 ecff9e032..475ea50ab 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,6 +1,7 @@ import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; +import { RecordNotFoundError } from '../../src/errors'; function createMockClient() { const mockAction = { execute: jest.fn() }; @@ -84,9 +85,10 @@ describe('AgentClientAgentPort', () => { }); }); - it('should throw when no record is found', async () => { + it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); + await expect(port.getRecord('users', '999')).rejects.toThrow(RecordNotFoundError); await expect(port.getRecord('users', '999')).rejects.toThrow( 'Record not found: collection "users", id "999"', ); @@ -116,18 +118,21 @@ describe('AgentClientAgentPort', () => { expect(result.fields).toEqual([expect.objectContaining({ fieldName: 'id', type: 'Number' })]); }); - it('should map ManyToOne fields as relationships', async () => { - mockCollection.list.mockResolvedValue([{ id: '1', company: 5 }]); - mockCollection.capabilities.mockResolvedValue({ - fields: [{ name: 'company', type: 'ManyToOne', operators: [] }], - }); + it.each(['ManyToOne', 'OneToOne', 'OneToMany', 'ManyToMany'])( + 'should map %s fields as relationships', + async type => { + mockCollection.list.mockResolvedValue([{ id: '1' }]); + mockCollection.capabilities.mockResolvedValue({ + fields: [{ name: 'rel', type, operators: [] }], + }); - const result = await port.getRecord('users', '1'); + const result = await port.getRecord('users', '1'); - expect(result.fields[0]).toEqual( - expect.objectContaining({ isRelationship: true, fieldName: 'company' }), - ); - }); + expect(result.fields[0]).toEqual( + expect.objectContaining({ isRelationship: true, fieldName: 'rel' }), + ); + }, + ); }); describe('updateRecord', () => { From 871c2fda30425fab1763816a2ce201be5f4e24f3 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 11:56:45 +0100 Subject: [PATCH 04/12] refactor(workflow-executor): inject CollectionRef instead of fetching capabilities Replace runtime capabilities fetching with CollectionRef injected at construction time. This removes the capabilities cache, the HTTP dependency on _internal/capabilities, and simplifies the adapter. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 67 +++------- .../adapters/agent-client-agent-port.test.ts | 125 +++++++----------- 2 files changed, 68 insertions(+), 124 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 8f76452c8..b0772ab34 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,32 +1,22 @@ import type { AgentPort } from '../ports/agent-port'; -import type { RecordData, RecordFieldRef } from '../types/record'; +import type { CollectionRef, RecordData } from '../types/record'; import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; -const RELATIONSHIP_TYPES = new Set(['ManyToOne', 'OneToOne', 'OneToMany', 'ManyToMany']); - -function toRecordFieldRef(field: { name: string; type: string }): RecordFieldRef { - return { - fieldName: field.name, - displayName: field.name, - type: field.type, - isRelationship: RELATIONSHIP_TYPES.has(field.type), - referencedCollectionName: undefined, - }; -} - export default class AgentClientAgentPort implements AgentPort { private readonly client: RemoteAgentClient; private readonly actionEndpoints: ActionEndpointsByCollection; - private readonly capabilitiesCache = new Map< - string, - Promise<{ fields: { name: string; type: string; operators: string[] }[] }> - >(); + private readonly collectionRefs: Record; - constructor(params: { client: RemoteAgentClient; actionEndpoints: ActionEndpointsByCollection }) { + constructor(params: { + client: RemoteAgentClient; + actionEndpoints: ActionEndpointsByCollection; + collectionRefs: Record; + }) { this.client = params.client; this.actionEndpoints = params.actionEndpoints; + this.collectionRefs = params.collectionRefs; } async getRecord(collectionName: string, recordId: string): Promise { @@ -39,13 +29,9 @@ export default class AgentClientAgentPort implements AgentPort { throw new RecordNotFoundError(collectionName, recordId); } - const capabilities = await this.getCapabilities(collectionName); - return { + ...this.getCollectionRef(collectionName), recordId, - collectionName, - collectionDisplayName: collectionName, - fields: capabilities.fields.map(toRecordFieldRef), values: records[0], }; } @@ -59,13 +45,9 @@ export default class AgentClientAgentPort implements AgentPort { .collection(collectionName) .update>(recordId, values); - const capabilities = await this.getCapabilities(collectionName); - return { + ...this.getCollectionRef(collectionName), recordId, - collectionName, - collectionDisplayName: collectionName, - fields: capabilities.fields.map(toRecordFieldRef), values: updatedRecord, }; } @@ -80,11 +62,15 @@ export default class AgentClientAgentPort implements AgentPort { .relation(relationName, recordId) .list>(); - return records.map(record => ({ - recordId: String(record.id ?? ''), + const ref = this.collectionRefs[relationName] ?? { collectionName: relationName, collectionDisplayName: relationName, fields: [], + }; + + return records.map(record => ({ + ...ref, + recordId: String(record.id ?? ''), values: record, })); } @@ -105,22 +91,13 @@ export default class AgentClientAgentPort implements AgentPort { return action.execute(); } - private getCapabilities( - collectionName: string, - ): Promise<{ fields: { name: string; type: string; operators: string[] }[] }> { - let cached = this.capabilitiesCache.get(collectionName); - - if (!cached) { - cached = this.client - .collection(collectionName) - .capabilities() - .catch(error => { - this.capabilitiesCache.delete(collectionName); - throw error; - }); - this.capabilitiesCache.set(collectionName, cached); + private getCollectionRef(collectionName: string): CollectionRef { + const ref = this.collectionRefs[collectionName]; + + if (!ref) { + return { collectionName, collectionDisplayName: collectionName, fields: [] }; } - return cached; + return ref; } } 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 475ea50ab..3a39c772d 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,3 +1,4 @@ +import type { CollectionRef } from '../../src/types/record'; import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; @@ -9,7 +10,6 @@ function createMockClient() { const mockCollection = { list: jest.fn(), update: jest.fn(), - capabilities: jest.fn(), relation: jest.fn().mockReturnValue(mockRelation), action: jest.fn().mockResolvedValue(mockAction), }; @@ -27,6 +27,7 @@ describe('AgentClientAgentPort', () => { let mockRelation: ReturnType['mockRelation']; let mockAction: ReturnType['mockAction']; let actionEndpoints: ActionEndpointsByCollection; + let collectionRefs: Record; let port: AgentClientAgentPort; beforeEach(() => { @@ -41,18 +42,31 @@ describe('AgentClientAgentPort', () => { }, }; - port = new AgentClientAgentPort({ client, actionEndpoints }); + collectionRefs = { + users: { + collectionName: 'users', + collectionDisplayName: 'Users', + fields: [ + { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, + { fieldName: 'name', displayName: 'name', type: 'String', isRelationship: false }, + ], + }, + posts: { + collectionName: 'posts', + collectionDisplayName: 'Posts', + fields: [ + { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, + { fieldName: 'title', displayName: 'title', type: 'String', isRelationship: false }, + ], + }, + }; + + port = new AgentClientAgentPort({ client, actionEndpoints, collectionRefs }); }); describe('getRecord', () => { - it('should return a RecordData when the record exists', async () => { + it('should return a RecordData using the provided CollectionRef', async () => { mockCollection.list.mockResolvedValue([{ id: '42', name: 'Alice' }]); - mockCollection.capabilities.mockResolvedValue({ - fields: [ - { name: 'id', type: 'Number', operators: ['equal'] }, - { name: 'name', type: 'String', operators: ['equal', 'contains'] }, - ], - }); const result = await port.getRecord('users', '42'); @@ -64,23 +78,8 @@ describe('AgentClientAgentPort', () => { expect(result).toEqual({ recordId: '42', collectionName: 'users', - collectionDisplayName: 'users', - fields: [ - { - fieldName: 'id', - displayName: 'id', - type: 'Number', - isRelationship: false, - referencedCollectionName: undefined, - }, - { - fieldName: 'name', - displayName: 'name', - type: 'String', - isRelationship: false, - referencedCollectionName: undefined, - }, - ], + collectionDisplayName: 'Users', + fields: collectionRefs.users.fields, values: { id: '42', name: 'Alice' }, }); }); @@ -94,53 +93,20 @@ describe('AgentClientAgentPort', () => { ); }); - it('should cache capabilities between calls on the same collection', async () => { - mockCollection.list.mockResolvedValue([{ id: '1' }]); - mockCollection.capabilities.mockResolvedValue({ fields: [] }); - - await port.getRecord('users', '1'); - await port.getRecord('users', '2'); - - expect(mockCollection.capabilities).toHaveBeenCalledTimes(1); - }); - - it('should evict cache and retry when capabilities rejects', async () => { + it('should fallback to empty fields when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: '1' }]); - mockCollection.capabilities - .mockRejectedValueOnce(new Error('Network error')) - .mockResolvedValueOnce({ fields: [{ name: 'id', type: 'Number', operators: [] }] }); - await expect(port.getRecord('users', '1')).rejects.toThrow('Network error'); + const result = await port.getRecord('unknown', '1'); - const result = await port.getRecord('users', '1'); - - expect(mockCollection.capabilities).toHaveBeenCalledTimes(2); - expect(result.fields).toEqual([expect.objectContaining({ fieldName: 'id', type: 'Number' })]); + expect(result.collectionName).toBe('unknown'); + expect(result.collectionDisplayName).toBe('unknown'); + expect(result.fields).toEqual([]); }); - - it.each(['ManyToOne', 'OneToOne', 'OneToMany', 'ManyToMany'])( - 'should map %s fields as relationships', - async type => { - mockCollection.list.mockResolvedValue([{ id: '1' }]); - mockCollection.capabilities.mockResolvedValue({ - fields: [{ name: 'rel', type, operators: [] }], - }); - - const result = await port.getRecord('users', '1'); - - expect(result.fields[0]).toEqual( - expect.objectContaining({ isRelationship: true, fieldName: 'rel' }), - ); - }, - ); }); describe('updateRecord', () => { it('should return a RecordData after update', async () => { mockCollection.update.mockResolvedValue({ id: '42', name: 'Bob' }); - mockCollection.capabilities.mockResolvedValue({ - fields: [{ name: 'name', type: 'String', operators: [] }], - }); const result = await port.updateRecord('users', '42', { name: 'Bob' }); @@ -148,23 +114,15 @@ describe('AgentClientAgentPort', () => { expect(result).toEqual({ recordId: '42', collectionName: 'users', - collectionDisplayName: 'users', - fields: [ - { - fieldName: 'name', - displayName: 'name', - type: 'String', - isRelationship: false, - referencedCollectionName: undefined, - }, - ], + collectionDisplayName: 'Users', + fields: collectionRefs.users.fields, values: { id: '42', name: 'Bob' }, }); }); }); describe('getRelatedData', () => { - it('should return a RecordData array', async () => { + it('should return a RecordData array with the related CollectionRef', async () => { mockRelation.list.mockResolvedValue([ { id: '10', title: 'Post A' }, { id: '11', title: 'Post B' }, @@ -178,20 +136,29 @@ describe('AgentClientAgentPort', () => { { recordId: '10', collectionName: 'posts', - collectionDisplayName: 'posts', - fields: [], + collectionDisplayName: 'Posts', + fields: collectionRefs.posts.fields, values: { id: '10', title: 'Post A' }, }, { recordId: '11', collectionName: 'posts', - collectionDisplayName: 'posts', - fields: [], + collectionDisplayName: 'Posts', + fields: collectionRefs.posts.fields, values: { id: '11', title: 'Post B' }, }, ]); }); + it('should fallback to relationName when no CollectionRef exists', async () => { + mockRelation.list.mockResolvedValue([{ id: '1' }]); + + const result = await port.getRelatedData('users', '42', 'unknownRelation'); + + expect(result[0].collectionName).toBe('unknownRelation'); + expect(result[0].fields).toEqual([]); + }); + it('should return an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); From f439e917dc201dade74711fed01044adbe597928 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 12:32:14 +0100 Subject: [PATCH 05/12] refactor(workflow-executor): move actions into CollectionRef, remove actionEndpoints Add ActionRef type and actions field to CollectionRef. The adapter constructor now only takes client + collectionRefs, removing the separate actionEndpoints parameter and the dual source of truth. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 22 ++++++------- packages/workflow-executor/src/index.ts | 2 +- .../workflow-executor/src/types/record.ts | 6 ++++ .../adapters/agent-client-agent-port.test.ts | 33 ++++++++++++------- 4 files changed, 38 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 b0772ab34..330239f6f 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,21 +1,18 @@ import type { AgentPort } from '../ports/agent-port'; import type { CollectionRef, RecordData } from '../types/record'; -import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; +import type { RemoteAgentClient } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; export default class AgentClientAgentPort implements AgentPort { private readonly client: RemoteAgentClient; - private readonly actionEndpoints: ActionEndpointsByCollection; private readonly collectionRefs: Record; constructor(params: { client: RemoteAgentClient; - actionEndpoints: ActionEndpointsByCollection; collectionRefs: Record; }) { this.client = params.client; - this.actionEndpoints = params.actionEndpoints; this.collectionRefs = params.collectionRefs; } @@ -62,11 +59,7 @@ export default class AgentClientAgentPort implements AgentPort { .relation(relationName, recordId) .list>(); - const ref = this.collectionRefs[relationName] ?? { - collectionName: relationName, - collectionDisplayName: relationName, - fields: [], - }; + const ref = this.getCollectionRef(relationName); return records.map(record => ({ ...ref, @@ -76,9 +69,9 @@ export default class AgentClientAgentPort implements AgentPort { } async getActions(collectionName: string): Promise { - const endpoints = this.actionEndpoints[collectionName]; + const ref = this.collectionRefs[collectionName]; - return endpoints ? Object.keys(endpoints) : []; + return ref ? ref.actions.map(a => a.name) : []; } async executeAction( @@ -95,7 +88,12 @@ export default class AgentClientAgentPort implements AgentPort { const ref = this.collectionRefs[collectionName]; if (!ref) { - return { collectionName, collectionDisplayName: collectionName, fields: [] }; + return { + collectionName, + collectionDisplayName: collectionName, + fields: [], + actions: [], + }; } return ref; diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index a6b485aed..c434071d8 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -19,7 +19,7 @@ export type { StepExecutionData, } from './types/step-execution-data'; -export type { RecordFieldRef, CollectionRef, RecordData } from './types/record'; +export type { RecordFieldRef, ActionRef, CollectionRef, RecordData } from './types/record'; export type { StepRecord, diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index de65788cd..f77104d84 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -8,10 +8,16 @@ export interface RecordFieldRef { referencedCollectionName?: string; } +export interface ActionRef { + name: string; + displayName: string; +} + export interface CollectionRef { collectionName: string; collectionDisplayName: string; fields: RecordFieldRef[]; + actions: ActionRef[]; } export interface RecordData extends CollectionRef { 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 3a39c772d..4c25690a6 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,5 +1,5 @@ import type { CollectionRef } from '../../src/types/record'; -import type { ActionEndpointsByCollection, RemoteAgentClient } from '@forestadmin/agent-client'; +import type { RemoteAgentClient } from '@forestadmin/agent-client'; import AgentClientAgentPort from '../../src/adapters/agent-client-agent-port'; import { RecordNotFoundError } from '../../src/errors'; @@ -26,7 +26,6 @@ describe('AgentClientAgentPort', () => { let mockCollection: ReturnType['mockCollection']; let mockRelation: ReturnType['mockRelation']; let mockAction: ReturnType['mockAction']; - let actionEndpoints: ActionEndpointsByCollection; let collectionRefs: Record; let port: AgentClientAgentPort; @@ -35,13 +34,6 @@ describe('AgentClientAgentPort', () => { ({ client, mockCollection, mockRelation, mockAction } = createMockClient()); - actionEndpoints = { - users: { - sendEmail: { name: 'Send Email', endpoint: '/forest/actions/send-email' }, - archive: { name: 'Archive', endpoint: '/forest/actions/archive' }, - }, - }; - collectionRefs = { users: { collectionName: 'users', @@ -50,6 +42,10 @@ describe('AgentClientAgentPort', () => { { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, { fieldName: 'name', displayName: 'name', type: 'String', isRelationship: false }, ], + actions: [ + { name: 'sendEmail', displayName: 'Send Email' }, + { name: 'archive', displayName: 'Archive' }, + ], }, posts: { collectionName: 'posts', @@ -58,10 +54,11 @@ describe('AgentClientAgentPort', () => { { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, { fieldName: 'title', displayName: 'title', type: 'String', isRelationship: false }, ], + actions: [], }, }; - port = new AgentClientAgentPort({ client, actionEndpoints, collectionRefs }); + port = new AgentClientAgentPort({ client, collectionRefs }); }); describe('getRecord', () => { @@ -80,6 +77,7 @@ describe('AgentClientAgentPort', () => { collectionName: 'users', collectionDisplayName: 'Users', fields: collectionRefs.users.fields, + actions: collectionRefs.users.actions, values: { id: '42', name: 'Alice' }, }); }); @@ -93,7 +91,7 @@ describe('AgentClientAgentPort', () => { ); }); - it('should fallback to empty fields when collection is unknown', async () => { + it('should fallback to empty fields and actions when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: '1' }]); const result = await port.getRecord('unknown', '1'); @@ -101,6 +99,7 @@ describe('AgentClientAgentPort', () => { expect(result.collectionName).toBe('unknown'); expect(result.collectionDisplayName).toBe('unknown'); expect(result.fields).toEqual([]); + expect(result.actions).toEqual([]); }); }); @@ -116,6 +115,7 @@ describe('AgentClientAgentPort', () => { collectionName: 'users', collectionDisplayName: 'Users', fields: collectionRefs.users.fields, + actions: collectionRefs.users.actions, values: { id: '42', name: 'Bob' }, }); }); @@ -138,6 +138,7 @@ describe('AgentClientAgentPort', () => { collectionName: 'posts', collectionDisplayName: 'Posts', fields: collectionRefs.posts.fields, + actions: collectionRefs.posts.actions, values: { id: '10', title: 'Post A' }, }, { @@ -145,6 +146,7 @@ describe('AgentClientAgentPort', () => { collectionName: 'posts', collectionDisplayName: 'Posts', fields: collectionRefs.posts.fields, + actions: collectionRefs.posts.actions, values: { id: '11', title: 'Post B' }, }, ]); @@ -157,6 +159,7 @@ describe('AgentClientAgentPort', () => { expect(result[0].collectionName).toBe('unknownRelation'); expect(result[0].fields).toEqual([]); + expect(result[0].actions).toEqual([]); }); it('should return an empty array when no related data exists', async () => { @@ -169,7 +172,7 @@ describe('AgentClientAgentPort', () => { }); describe('getActions', () => { - it('should return action names from actionEndpoints', async () => { + it('should return action names from CollectionRef', async () => { const result = await port.getActions('users'); expect(result).toEqual(['sendEmail', 'archive']); @@ -180,6 +183,12 @@ describe('AgentClientAgentPort', () => { expect(result).toEqual([]); }); + + it('should return an empty array for a collection with no actions', async () => { + const result = await port.getActions('posts'); + + expect(result).toEqual([]); + }); }); describe('executeAction', () => { From 9594223c095899f9fcfdd8db6b371246f952eb33 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 13:02:05 +0100 Subject: [PATCH 06/12] refactor(workflow-executor): return ActionRef[] from getActions instead of string[] Update AgentPort interface and adapter to return ActionRef[] (name + displayName) from getActions, making the display name available to consumers without a separate lookup. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 6 +++--- packages/workflow-executor/src/ports/agent-port.ts | 4 ++-- .../test/adapters/agent-client-agent-port.test.ts | 5 ++++- 3 files changed, 9 insertions(+), 6 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 330239f6f..475df487b 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 { CollectionRef, RecordData } from '../types/record'; +import type { ActionRef, CollectionRef, RecordData } from '../types/record'; import type { RemoteAgentClient } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; @@ -68,10 +68,10 @@ export default class AgentClientAgentPort implements AgentPort { })); } - async getActions(collectionName: string): Promise { + async getActions(collectionName: string): Promise { const ref = this.collectionRefs[collectionName]; - return ref ? ref.actions.map(a => a.name) : []; + return ref ? ref.actions : []; } async executeAction( diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 5d4f431c7..a2e4c37cd 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 { RecordData } from '../types/record'; +import type { ActionRef, RecordData } from '../types/record'; export interface AgentPort { getRecord(collectionName: string, recordId: string): Promise; @@ -14,6 +14,6 @@ export interface AgentPort { recordId: string, relationName: string, ): Promise; - getActions(collectionName: string): Promise; + getActions(collectionName: string): Promise; executeAction(collectionName: string, actionName: string, recordIds: string[]): Promise; } 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 4c25690a6..91b2bf802 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 @@ -175,7 +175,10 @@ describe('AgentClientAgentPort', () => { it('should return action names from CollectionRef', async () => { const result = await port.getActions('users'); - expect(result).toEqual(['sendEmail', 'archive']); + expect(result).toEqual([ + { name: 'sendEmail', displayName: 'Send Email' }, + { name: 'archive', displayName: 'Archive' }, + ]); }); it('should return an empty array for an unknown collection', async () => { From 182fa8416b71d0ad1d1bc30a793848105275f7ff Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 14:20:22 +0100 Subject: [PATCH 07/12] fix(workflow-executor): support composite PKs in getRecord via primaryKeyFields Add primaryKeyFields to CollectionRef. buildPkFilter() generates a single Equal filter for simple PKs, or an And condition tree for composite PKs (separator: '|'). Fixes the hardcoded 'id' assumption. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 37 +++++++++++----- .../workflow-executor/src/types/record.ts | 1 + .../adapters/agent-client-agent-port.test.ts | 44 +++++++++++++++++-- 3 files changed, 66 insertions(+), 16 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 475df487b..24e00a6cf 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,9 +1,28 @@ import type { AgentPort } from '../ports/agent-port'; import type { ActionRef, CollectionRef, RecordData } from '../types/record'; -import type { RemoteAgentClient } from '@forestadmin/agent-client'; +import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; +const PK_SEPARATOR = '|'; + +function buildPkFilter(primaryKeyFields: string[], recordId: string): SelectOptions['filters'] { + const values = recordId.split(PK_SEPARATOR); + + if (primaryKeyFields.length === 1) { + return { field: primaryKeyFields[0], operator: 'Equal', value: values[0] }; + } + + return { + aggregator: 'And', + conditions: primaryKeyFields.map((field, i) => ({ + field, + operator: 'Equal', + value: values[i], + })), + }; +} + export default class AgentClientAgentPort implements AgentPort { private readonly client: RemoteAgentClient; private readonly collectionRefs: Record; @@ -17,8 +36,9 @@ export default class AgentClientAgentPort implements AgentPort { } async getRecord(collectionName: string, recordId: string): Promise { + const ref = this.getCollectionRef(collectionName); const records = await this.client.collection(collectionName).list>({ - filters: { field: 'id', operator: 'Equal', value: recordId }, + filters: buildPkFilter(ref.primaryKeyFields, recordId), pagination: { size: 1, number: 1 }, }); @@ -26,11 +46,7 @@ export default class AgentClientAgentPort implements AgentPort { throw new RecordNotFoundError(collectionName, recordId); } - return { - ...this.getCollectionRef(collectionName), - recordId, - values: records[0], - }; + return { ...ref, recordId, values: records[0] }; } async updateRecord( @@ -42,11 +58,7 @@ export default class AgentClientAgentPort implements AgentPort { .collection(collectionName) .update>(recordId, values); - return { - ...this.getCollectionRef(collectionName), - recordId, - values: updatedRecord, - }; + return { ...this.getCollectionRef(collectionName), recordId, values: updatedRecord }; } async getRelatedData( @@ -91,6 +103,7 @@ export default class AgentClientAgentPort implements AgentPort { return { collectionName, collectionDisplayName: collectionName, + primaryKeyFields: ['id'], fields: [], actions: [], }; diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index f77104d84..32f571360 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -16,6 +16,7 @@ export interface ActionRef { export interface CollectionRef { collectionName: string; collectionDisplayName: string; + primaryKeyFields: string[]; fields: RecordFieldRef[]; actions: ActionRef[]; } 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 91b2bf802..c490236c4 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 @@ -38,6 +38,7 @@ describe('AgentClientAgentPort', () => { users: { collectionName: 'users', collectionDisplayName: 'Users', + primaryKeyFields: ['id'], fields: [ { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, { fieldName: 'name', displayName: 'name', type: 'String', isRelationship: false }, @@ -47,9 +48,20 @@ describe('AgentClientAgentPort', () => { { name: 'archive', displayName: 'Archive' }, ], }, + orders: { + collectionName: 'orders', + collectionDisplayName: 'Orders', + primaryKeyFields: ['tenantId', 'orderId'], + fields: [ + { fieldName: 'tenantId', displayName: 'Tenant', type: 'Number', isRelationship: false }, + { fieldName: 'orderId', displayName: 'Order', type: 'Number', isRelationship: false }, + ], + actions: [], + }, posts: { collectionName: 'posts', collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], fields: [ { fieldName: 'id', displayName: 'id', type: 'Number', isRelationship: false }, { fieldName: 'title', displayName: 'title', type: 'String', isRelationship: false }, @@ -76,12 +88,30 @@ describe('AgentClientAgentPort', () => { recordId: '42', collectionName: 'users', collectionDisplayName: 'Users', + primaryKeyFields: ['id'], fields: collectionRefs.users.fields, actions: collectionRefs.users.actions, values: { id: '42', name: 'Alice' }, }); }); + it('should build a composite filter for composite primary keys', async () => { + mockCollection.list.mockResolvedValue([{ tenantId: '1', orderId: '2' }]); + + await port.getRecord('orders', '1|2'); + + expect(mockCollection.list).toHaveBeenCalledWith({ + filters: { + aggregator: 'And', + conditions: [ + { field: 'tenantId', operator: 'Equal', value: '1' }, + { field: 'orderId', operator: 'Equal', value: '2' }, + ], + }, + pagination: { size: 1, number: 1 }, + }); + }); + it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); @@ -91,15 +121,18 @@ describe('AgentClientAgentPort', () => { ); }); - it('should fallback to empty fields and actions when collection is unknown', async () => { + it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: '1' }]); const result = await port.getRecord('unknown', '1'); + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { field: 'id', operator: 'Equal', value: '1' }, + }), + ); expect(result.collectionName).toBe('unknown'); - expect(result.collectionDisplayName).toBe('unknown'); expect(result.fields).toEqual([]); - expect(result.actions).toEqual([]); }); }); @@ -114,6 +147,7 @@ describe('AgentClientAgentPort', () => { recordId: '42', collectionName: 'users', collectionDisplayName: 'Users', + primaryKeyFields: ['id'], fields: collectionRefs.users.fields, actions: collectionRefs.users.actions, values: { id: '42', name: 'Bob' }, @@ -137,6 +171,7 @@ describe('AgentClientAgentPort', () => { recordId: '10', collectionName: 'posts', collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], fields: collectionRefs.posts.fields, actions: collectionRefs.posts.actions, values: { id: '10', title: 'Post A' }, @@ -145,6 +180,7 @@ describe('AgentClientAgentPort', () => { recordId: '11', collectionName: 'posts', collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], fields: collectionRefs.posts.fields, actions: collectionRefs.posts.actions, values: { id: '11', title: 'Post B' }, @@ -172,7 +208,7 @@ describe('AgentClientAgentPort', () => { }); describe('getActions', () => { - it('should return action names from CollectionRef', async () => { + it('should return ActionRef[] from CollectionRef', async () => { const result = await port.getActions('users'); expect(result).toEqual([ From cdeb2dc1980cd009f947ec3d060917d511c925af Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 14:39:49 +0100 Subject: [PATCH 08/12] refactor(workflow-executor): use Record for recordId instead of pipe-encoded string Decouples the public API from the Forest Admin pipe encoding convention. Pipe encoding is now an internal implementation detail of AgentClientAgentPort only. Co-Authored-By: Claude Opus 4.6 --- .../src/adapters/agent-client-agent-port.ts | 62 +++++----- .../workflow-executor/src/ports/agent-port.ts | 12 +- .../workflow-executor/src/types/record.ts | 3 +- .../adapters/agent-client-agent-port.test.ts | 110 ++++++++---------- 4 files changed, 95 insertions(+), 92 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 24e00a6cf..dcdc12505 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,28 +1,32 @@ +import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; + import type { AgentPort } from '../ports/agent-port'; import type { ActionRef, CollectionRef, RecordData } from '../types/record'; -import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; -const PK_SEPARATOR = '|'; - -function buildPkFilter(primaryKeyFields: string[], recordId: string): SelectOptions['filters'] { - const values = recordId.split(PK_SEPARATOR); +function buildPkFilter(recordId: Record): SelectOptions['filters'] { + const entries = Object.entries(recordId); - if (primaryKeyFields.length === 1) { - return { field: primaryKeyFields[0], operator: 'Equal', value: values[0] }; + if (entries.length === 1) { + return { field: entries[0][0], operator: 'Equal', value: entries[0][1] }; } return { aggregator: 'And', - conditions: primaryKeyFields.map((field, i) => ({ - field, - operator: 'Equal', - value: values[i], - })), + conditions: entries.map(([field, value]) => ({ field, operator: 'Equal', value })), }; } +// agent-client methods (update, relation, action) still expect the pipe-encoded string format +function encodeRecordId(primaryKeyFields: string[], recordId: Record): string { + return primaryKeyFields.map(field => String(recordId[field] ?? '')).join('|'); +} + +function extractRecordId(primaryKeyFields: string[], record: Record): Record { + return Object.fromEntries(primaryKeyFields.map(field => [field, record[field]])); +} + export default class AgentClientAgentPort implements AgentPort { private readonly client: RemoteAgentClient; private readonly collectionRefs: Record; @@ -35,15 +39,15 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionRefs = params.collectionRefs; } - async getRecord(collectionName: string, recordId: string): Promise { + async getRecord(collectionName: string, recordId: Record): Promise { const ref = this.getCollectionRef(collectionName); const records = await this.client.collection(collectionName).list>({ - filters: buildPkFilter(ref.primaryKeyFields, recordId), + filters: buildPkFilter(recordId), pagination: { size: 1, number: 1 }, }); if (records.length === 0) { - throw new RecordNotFoundError(collectionName, recordId); + throw new RecordNotFoundError(collectionName, encodeRecordId(ref.primaryKeyFields, recordId)); } return { ...ref, recordId, values: records[0] }; @@ -51,31 +55,33 @@ export default class AgentClientAgentPort implements AgentPort { async updateRecord( collectionName: string, - recordId: string, + recordId: Record, values: Record, ): Promise { + const ref = this.getCollectionRef(collectionName); const updatedRecord = await this.client .collection(collectionName) - .update>(recordId, values); + .update>(encodeRecordId(ref.primaryKeyFields, recordId), values); - return { ...this.getCollectionRef(collectionName), recordId, values: updatedRecord }; + return { ...ref, recordId, values: updatedRecord }; } async getRelatedData( collectionName: string, - recordId: string, + recordId: Record, relationName: string, ): Promise { + const ref = this.getCollectionRef(collectionName); + const relatedRef = this.getCollectionRef(relationName); + const records = await this.client .collection(collectionName) - .relation(relationName, recordId) + .relation(relationName, encodeRecordId(ref.primaryKeyFields, recordId)) .list>(); - const ref = this.getCollectionRef(relationName); - return records.map(record => ({ - ...ref, - recordId: String(record.id ?? ''), + ...relatedRef, + recordId: extractRecordId(relatedRef.primaryKeyFields, record), values: record, })); } @@ -89,9 +95,13 @@ export default class AgentClientAgentPort implements AgentPort { async executeAction( collectionName: string, actionName: string, - recordIds: string[], + recordIds: Record[], ): Promise { - const action = await this.client.collection(collectionName).action(actionName, { recordIds }); + const ref = this.getCollectionRef(collectionName); + const encodedIds = recordIds.map(id => encodeRecordId(ref.primaryKeyFields, id)); + const action = await this.client + .collection(collectionName) + .action(actionName, { recordIds: encodedIds }); return action.execute(); } diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index a2e4c37cd..6965a90e7 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -3,17 +3,21 @@ import type { ActionRef, RecordData } from '../types/record'; export interface AgentPort { - getRecord(collectionName: string, recordId: string): Promise; + getRecord(collectionName: string, recordId: Record): Promise; updateRecord( collectionName: string, - recordId: string, + recordId: Record, values: Record, ): Promise; getRelatedData( collectionName: string, - recordId: string, + recordId: Record, relationName: string, ): Promise; getActions(collectionName: string): Promise; - executeAction(collectionName: string, actionName: string, recordIds: string[]): Promise; + executeAction( + collectionName: string, + actionName: string, + recordIds: Record[], + ): Promise; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 32f571360..05ab9c625 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -22,6 +22,7 @@ export interface CollectionRef { } export interface RecordData extends CollectionRef { - recordId: string; + // TODO: improve recordId typing — consider a branded type or a stricter shape once the API stabilizes + recordId: Record; values: Record; } 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 c490236c4..2aac7fea1 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 @@ -74,38 +74,37 @@ describe('AgentClientAgentPort', () => { }); describe('getRecord', () => { - it('should return a RecordData using the provided CollectionRef', async () => { - mockCollection.list.mockResolvedValue([{ id: '42', name: 'Alice' }]); + it('should return a RecordData for a simple PK', async () => { + mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - const result = await port.getRecord('users', '42'); + const result = await port.getRecord('users', { id: 42 }); - expect(client.collection).toHaveBeenCalledWith('users'); expect(mockCollection.list).toHaveBeenCalledWith({ - filters: { field: 'id', operator: 'Equal', value: '42' }, + filters: { field: 'id', operator: 'Equal', value: 42 }, pagination: { size: 1, number: 1 }, }); expect(result).toEqual({ - recordId: '42', + recordId: { id: 42 }, collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], fields: collectionRefs.users.fields, actions: collectionRefs.users.actions, - values: { id: '42', name: 'Alice' }, + values: { id: 42, name: 'Alice' }, }); }); - it('should build a composite filter for composite primary keys', async () => { - mockCollection.list.mockResolvedValue([{ tenantId: '1', orderId: '2' }]); + it('should build a composite And filter for composite PKs', async () => { + mockCollection.list.mockResolvedValue([{ tenantId: 1, orderId: 2 }]); - await port.getRecord('orders', '1|2'); + await port.getRecord('orders', { tenantId: 1, orderId: 2 }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { aggregator: 'And', conditions: [ - { field: 'tenantId', operator: 'Equal', value: '1' }, - { field: 'orderId', operator: 'Equal', value: '2' }, + { field: 'tenantId', operator: 'Equal', value: 1 }, + { field: 'orderId', operator: 'Equal', value: 2 }, ], }, pagination: { size: 1, number: 1 }, @@ -115,20 +114,17 @@ describe('AgentClientAgentPort', () => { it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); - await expect(port.getRecord('users', '999')).rejects.toThrow(RecordNotFoundError); - await expect(port.getRecord('users', '999')).rejects.toThrow( - 'Record not found: collection "users", id "999"', - ); + await expect(port.getRecord('users', { id: 999 })).rejects.toThrow(RecordNotFoundError); }); it('should fallback to pk field "id" when collection is unknown', async () => { - mockCollection.list.mockResolvedValue([{ id: '1' }]); + mockCollection.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRecord('unknown', '1'); + const result = await port.getRecord('unknown', { id: 1 }); expect(mockCollection.list).toHaveBeenCalledWith( expect.objectContaining({ - filters: { field: 'id', operator: 'Equal', value: '1' }, + filters: { field: 'id', operator: 'Equal', value: 1 }, }), ); expect(result.collectionName).toBe('unknown'); @@ -137,117 +133,109 @@ describe('AgentClientAgentPort', () => { }); describe('updateRecord', () => { - it('should return a RecordData after update', async () => { - mockCollection.update.mockResolvedValue({ id: '42', name: 'Bob' }); + it('should call update with pipe-encoded id and return a RecordData', async () => { + mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' }); - const result = await port.updateRecord('users', '42', { name: 'Bob' }); + const result = await port.updateRecord('users', { id: 42 }, { name: 'Bob' }); expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); expect(result).toEqual({ - recordId: '42', + recordId: { id: 42 }, collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], fields: collectionRefs.users.fields, actions: collectionRefs.users.actions, - values: { id: '42', name: 'Bob' }, + values: { id: 42, name: 'Bob' }, }); }); + + it('should encode composite PK to pipe format for update', async () => { + mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); + + await port.updateRecord('orders', { tenantId: 1, orderId: 2 }, { status: 'done' }); + + expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); + }); }); describe('getRelatedData', () => { - it('should return a RecordData array with the related CollectionRef', async () => { + it('should return RecordData[] with recordId extracted from PK fields', async () => { mockRelation.list.mockResolvedValue([ - { id: '10', title: 'Post A' }, - { id: '11', title: 'Post B' }, + { id: 10, title: 'Post A' }, + { id: 11, title: 'Post B' }, ]); - const result = await port.getRelatedData('users', '42', 'posts'); + const result = await port.getRelatedData('users', { id: 42 }, 'posts'); - expect(client.collection).toHaveBeenCalledWith('users'); expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); expect(result).toEqual([ { - recordId: '10', + recordId: { id: 10 }, collectionName: 'posts', collectionDisplayName: 'Posts', primaryKeyFields: ['id'], fields: collectionRefs.posts.fields, actions: collectionRefs.posts.actions, - values: { id: '10', title: 'Post A' }, + values: { id: 10, title: 'Post A' }, }, { - recordId: '11', + recordId: { id: 11 }, collectionName: 'posts', collectionDisplayName: 'Posts', primaryKeyFields: ['id'], fields: collectionRefs.posts.fields, actions: collectionRefs.posts.actions, - values: { id: '11', title: 'Post B' }, + values: { id: 11, title: 'Post B' }, }, ]); }); it('should fallback to relationName when no CollectionRef exists', async () => { - mockRelation.list.mockResolvedValue([{ id: '1' }]); + mockRelation.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRelatedData('users', '42', 'unknownRelation'); + const result = await port.getRelatedData('users', { id: 42 }, 'unknownRelation'); expect(result[0].collectionName).toBe('unknownRelation'); - expect(result[0].fields).toEqual([]); - expect(result[0].actions).toEqual([]); + expect(result[0].recordId).toEqual({ id: 1 }); }); it('should return an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); - const result = await port.getRelatedData('users', '42', 'posts'); - - expect(result).toEqual([]); + expect(await port.getRelatedData('users', { id: 42 }, 'posts')).toEqual([]); }); }); describe('getActions', () => { it('should return ActionRef[] from CollectionRef', async () => { - const result = await port.getActions('users'); - - expect(result).toEqual([ + 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 () => { - const result = await port.getActions('unknown'); - - expect(result).toEqual([]); - }); - - it('should return an empty array for a collection with no actions', async () => { - const result = await port.getActions('posts'); - - expect(result).toEqual([]); + expect(await port.getActions('unknown')).toEqual([]); }); }); describe('executeAction', () => { - it('should call action then execute with the correct recordIds', async () => { - mockAction.execute.mockResolvedValue({ success: 'Email sent' }); + it('should encode recordIds to pipe format and call execute', async () => { + mockAction.execute.mockResolvedValue({ success: 'done' }); - const result = await port.executeAction('users', 'sendEmail', ['1', '2']); + const result = await port.executeAction('users', 'sendEmail', [{ id: 1 }, { id: 2 }]); - expect(client.collection).toHaveBeenCalledWith('users'); expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); - expect(mockAction.execute).toHaveBeenCalledTimes(1); - expect(result).toEqual({ success: 'Email sent' }); + expect(result).toEqual({ success: 'done' }); }); it('should propagate errors from action execution', async () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); - await expect(port.executeAction('users', 'sendEmail', ['1'])).rejects.toThrow( - 'Action failed', - ); + await expect( + port.executeAction('users', 'sendEmail', [{ id: 1 }]), + ).rejects.toThrow('Action failed'); }); }); }); From 2a2d898d4da2fda8b0954e34493f8c406dfe444e Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 14:56:11 +0100 Subject: [PATCH 09/12] refactor(workflow-executor): change recordId to Array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callers no longer need to know PK field names — they pass values in primaryKeyFields order. Pipe encoding stays internal to encodePk(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/adapters/agent-client-agent-port.ts | 51 ++++++++++--------- .../workflow-executor/src/ports/agent-port.ts | 8 +-- .../workflow-executor/src/types/record.ts | 3 +- .../adapters/agent-client-agent-port.test.ts | 36 ++++++------- 4 files changed, 51 insertions(+), 47 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 dcdc12505..23015e8bc 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,30 +1,37 @@ -import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; - import type { AgentPort } from '../ports/agent-port'; import type { ActionRef, CollectionRef, RecordData } from '../types/record'; +import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; import { RecordNotFoundError } from '../errors'; -function buildPkFilter(recordId: Record): SelectOptions['filters'] { - const entries = Object.entries(recordId); - - if (entries.length === 1) { - return { field: entries[0][0], operator: 'Equal', value: entries[0][1] }; +function buildPkFilter( + primaryKeyFields: string[], + recordId: Array, +): SelectOptions['filters'] { + if (primaryKeyFields.length === 1) { + return { field: primaryKeyFields[0], operator: 'Equal', value: recordId[0] }; } return { aggregator: 'And', - conditions: entries.map(([field, value]) => ({ field, operator: 'Equal', value })), + conditions: primaryKeyFields.map((field, i) => ({ + field, + operator: 'Equal', + value: recordId[i], + })), }; } // agent-client methods (update, relation, action) still expect the pipe-encoded string format -function encodeRecordId(primaryKeyFields: string[], recordId: Record): string { - return primaryKeyFields.map(field => String(recordId[field] ?? '')).join('|'); +function encodePk(recordId: Array): string { + return recordId.map(v => String(v)).join('|'); } -function extractRecordId(primaryKeyFields: string[], record: Record): Record { - return Object.fromEntries(primaryKeyFields.map(field => [field, record[field]])); +function extractRecordId( + primaryKeyFields: string[], + record: Record, +): Array { + return primaryKeyFields.map(field => record[field] as string | number); } export default class AgentClientAgentPort implements AgentPort { @@ -39,15 +46,15 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionRefs = params.collectionRefs; } - async getRecord(collectionName: string, recordId: Record): Promise { + async getRecord(collectionName: string, recordId: Array): Promise { const ref = this.getCollectionRef(collectionName); const records = await this.client.collection(collectionName).list>({ - filters: buildPkFilter(recordId), + filters: buildPkFilter(ref.primaryKeyFields, recordId), pagination: { size: 1, number: 1 }, }); if (records.length === 0) { - throw new RecordNotFoundError(collectionName, encodeRecordId(ref.primaryKeyFields, recordId)); + throw new RecordNotFoundError(collectionName, encodePk(recordId)); } return { ...ref, recordId, values: records[0] }; @@ -55,28 +62,27 @@ export default class AgentClientAgentPort implements AgentPort { async updateRecord( collectionName: string, - recordId: Record, + recordId: Array, values: Record, ): Promise { const ref = this.getCollectionRef(collectionName); const updatedRecord = await this.client .collection(collectionName) - .update>(encodeRecordId(ref.primaryKeyFields, recordId), values); + .update>(encodePk(recordId), values); return { ...ref, recordId, values: updatedRecord }; } async getRelatedData( collectionName: string, - recordId: Record, + recordId: Array, relationName: string, ): Promise { - const ref = this.getCollectionRef(collectionName); const relatedRef = this.getCollectionRef(relationName); const records = await this.client .collection(collectionName) - .relation(relationName, encodeRecordId(ref.primaryKeyFields, recordId)) + .relation(relationName, encodePk(recordId)) .list>(); return records.map(record => ({ @@ -95,10 +101,9 @@ export default class AgentClientAgentPort implements AgentPort { async executeAction( collectionName: string, actionName: string, - recordIds: Record[], + recordIds: Array[], ): Promise { - const ref = this.getCollectionRef(collectionName); - const encodedIds = recordIds.map(id => encodeRecordId(ref.primaryKeyFields, id)); + const encodedIds = recordIds.map(id => encodePk(id)); const action = await this.client .collection(collectionName) .action(actionName, { recordIds: encodedIds }); diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 6965a90e7..6a588f1f2 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -3,21 +3,21 @@ import type { ActionRef, RecordData } from '../types/record'; export interface AgentPort { - getRecord(collectionName: string, recordId: Record): Promise; + getRecord(collectionName: string, recordId: Array): Promise; updateRecord( collectionName: string, - recordId: Record, + recordId: Array, values: Record, ): Promise; getRelatedData( collectionName: string, - recordId: Record, + recordId: Array, relationName: string, ): Promise; getActions(collectionName: string): Promise; executeAction( collectionName: string, actionName: string, - recordIds: Record[], + recordIds: Array[], ): Promise; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index 05ab9c625..14064fcb1 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -22,7 +22,6 @@ export interface CollectionRef { } export interface RecordData extends CollectionRef { - // TODO: improve recordId typing — consider a branded type or a stricter shape once the API stabilizes - recordId: Record; + recordId: Array; values: Record; } 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 2aac7fea1..878990787 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -77,14 +77,14 @@ describe('AgentClientAgentPort', () => { it('should return a RecordData for a simple PK', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - const result = await port.getRecord('users', { id: 42 }); + const result = await port.getRecord('users', [42]); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, pagination: { size: 1, number: 1 }, }); expect(result).toEqual({ - recordId: { id: 42 }, + recordId: [42], collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], @@ -97,7 +97,7 @@ describe('AgentClientAgentPort', () => { it('should build a composite And filter for composite PKs', async () => { mockCollection.list.mockResolvedValue([{ tenantId: 1, orderId: 2 }]); - await port.getRecord('orders', { tenantId: 1, orderId: 2 }); + await port.getRecord('orders', [1, 2]); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { @@ -114,13 +114,13 @@ describe('AgentClientAgentPort', () => { it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); - await expect(port.getRecord('users', { id: 999 })).rejects.toThrow(RecordNotFoundError); + await expect(port.getRecord('users', [999])).rejects.toThrow(RecordNotFoundError); }); it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRecord('unknown', { id: 1 }); + const result = await port.getRecord('unknown', [1]); expect(mockCollection.list).toHaveBeenCalledWith( expect.objectContaining({ @@ -136,11 +136,11 @@ describe('AgentClientAgentPort', () => { it('should call update with pipe-encoded id and return a RecordData', async () => { mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' }); - const result = await port.updateRecord('users', { id: 42 }, { name: 'Bob' }); + const result = await port.updateRecord('users', [42], { name: 'Bob' }); expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); expect(result).toEqual({ - recordId: { id: 42 }, + recordId: [42], collectionName: 'users', collectionDisplayName: 'Users', primaryKeyFields: ['id'], @@ -153,7 +153,7 @@ describe('AgentClientAgentPort', () => { it('should encode composite PK to pipe format for update', async () => { mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); - await port.updateRecord('orders', { tenantId: 1, orderId: 2 }, { status: 'done' }); + await port.updateRecord('orders', [1, 2], { status: 'done' }); expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); }); @@ -166,12 +166,12 @@ describe('AgentClientAgentPort', () => { { id: 11, title: 'Post B' }, ]); - const result = await port.getRelatedData('users', { id: 42 }, 'posts'); + const result = await port.getRelatedData('users', [42], 'posts'); expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); expect(result).toEqual([ { - recordId: { id: 10 }, + recordId: [10], collectionName: 'posts', collectionDisplayName: 'Posts', primaryKeyFields: ['id'], @@ -180,7 +180,7 @@ describe('AgentClientAgentPort', () => { values: { id: 10, title: 'Post A' }, }, { - recordId: { id: 11 }, + recordId: [11], collectionName: 'posts', collectionDisplayName: 'Posts', primaryKeyFields: ['id'], @@ -194,16 +194,16 @@ describe('AgentClientAgentPort', () => { it('should fallback to relationName when no CollectionRef exists', async () => { mockRelation.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRelatedData('users', { id: 42 }, 'unknownRelation'); + const result = await port.getRelatedData('users', [42], 'unknownRelation'); expect(result[0].collectionName).toBe('unknownRelation'); - expect(result[0].recordId).toEqual({ id: 1 }); + expect(result[0].recordId).toEqual([1]); }); it('should return an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); - expect(await port.getRelatedData('users', { id: 42 }, 'posts')).toEqual([]); + expect(await port.getRelatedData('users', [42], 'posts')).toEqual([]); }); }); @@ -224,7 +224,7 @@ describe('AgentClientAgentPort', () => { it('should encode recordIds to pipe format and call execute', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); - const result = await port.executeAction('users', 'sendEmail', [{ id: 1 }, { id: 2 }]); + const result = await port.executeAction('users', 'sendEmail', [[1], [2]]); expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); expect(result).toEqual({ success: 'done' }); @@ -233,9 +233,9 @@ describe('AgentClientAgentPort', () => { it('should propagate errors from action execution', async () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); - await expect( - port.executeAction('users', 'sendEmail', [{ id: 1 }]), - ).rejects.toThrow('Action failed'); + await expect(port.executeAction('users', 'sendEmail', [[1]])).rejects.toThrow( + 'Action failed', + ); }); }); }); From 2703ae7a88097aad6c90e1fe76f0fcbf25f2225b Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 15:07:54 +0100 Subject: [PATCH 10/12] fix(workflow-executor): rename RecordRef to CollectionRef in execution.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/workflow-executor/src/types/execution.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index e983aad4b..d2524403c 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 { RecordRef } from './record'; +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 +20,7 @@ export interface PendingStepExecution { readonly step: StepDefinition; readonly stepHistory: StepHistory; readonly previousSteps: ReadonlyArray; - readonly availableRecords: ReadonlyArray; + readonly availableRecords: ReadonlyArray; readonly userInput?: UserInput; } From 889ea3bb1f17be97c16f84c3471a8c243c1b16c7 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 15:12:53 +0100 Subject: [PATCH 11/12] fix(workflow-executor): merge duplicate dependencies blocks in package.json Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/workflow-executor/package.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index d2579c1e3..fafc832c5 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -15,9 +15,6 @@ "dist/**/*.js", "dist/**/*.d.ts" ], - "dependencies": { - "@forestadmin/agent-client": "1.4.13" - }, "scripts": { "build": "tsc", "build:watch": "tsc --watch", @@ -26,6 +23,7 @@ "test": "jest" }, "dependencies": { + "@forestadmin/agent-client": "1.4.13", "@langchain/core": "1.1.33", "zod": "4.3.6" } From e7fcf1b072253f625eb947b66c9484ef52ecc735 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 15:21:43 +0100 Subject: [PATCH 12/12] fix(workflow-executor): revert selectedRecord type to CollectionRef Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/workflow-executor/src/types/step-execution-data.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 46bd0f981..e2d46eaf4 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 } from './record'; +import type { CollectionRef } from './record'; interface BaseStepExecutionData { stepIndex: number; @@ -17,7 +17,7 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { executionParams?: Record; executionResult?: Record; toolConfirmationInterruption?: Record; - selectedRecord?: RecordData; + selectedRecord?: CollectionRef; } export type StepExecutionData = ConditionStepExecutionData | AiTaskStepExecutionData;