From a0dd5a9d4a7dd942a02dddaaa01f6df973d3fa5f Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 18 Mar 2026 16:51:43 +0100 Subject: [PATCH] feat(workflow-executor): implement WorkflowPort adapter using forestadmin-client Adds ForestServerWorkflowPort that communicates with the Forest Admin server via HTTP (ServerUtils.query) for workflow step orchestration. Renames completeStepExecution to updateStepExecution. Exports ServerUtils from forestadmin-client. fixes PRD-233 Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/forestadmin-client/src/index.ts | 1 + packages/workflow-executor/package.json | 1 + .../adapters/forest-server-workflow-port.ts | 53 +++++++++ packages/workflow-executor/src/index.ts | 1 + .../src/ports/workflow-port.ts | 2 +- .../forest-server-workflow-port.test.ts | 105 ++++++++++++++++++ 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/workflow-executor/src/adapters/forest-server-workflow-port.ts create mode 100644 packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 4957d2c573..1d50616c52 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -90,6 +90,7 @@ export { default as ForestAdminClientWithCache } from './forest-admin-client-wit export { default as buildApplicationServices } from './build-application-services'; export { HttpOptions } from './utils/http-options'; export { default as ForestHttpApi } from './permissions/forest-http-api'; +export { default as ServerUtils } from './utils/server'; // export is necessary for the agent-generator package export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index fafc832c59..3138b9a5d9 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@forestadmin/agent-client": "1.4.13", + "@forestadmin/forestadmin-client": "1.37.17", "@langchain/core": "1.1.33", "zod": "4.3.6" } diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts new file mode 100644 index 0000000000..e804e01cfa --- /dev/null +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -0,0 +1,53 @@ +import type { McpConfiguration, WorkflowPort } from '../ports/workflow-port'; +import type { PendingStepExecution } from '../types/execution'; +import type { CollectionRef } from '../types/record'; +import type { StepHistory } from '../types/step-history'; +import type { HttpOptions } from '@forestadmin/forestadmin-client'; + +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}`, + mcpServerConfigs: '/liana/mcp-server-configs-with-details', +}; + +export default class ForestServerWorkflowPort implements WorkflowPort { + private readonly options: HttpOptions; + + constructor(params: { envSecret: string; forestServerUrl: string }) { + this.options = { envSecret: params.envSecret, forestServerUrl: params.forestServerUrl }; + } + + async getPendingStepExecutions(): Promise { + return ServerUtils.query( + this.options, + 'get', + ROUTES.pendingStepExecutions, + ); + } + + async updateStepExecution(runId: string, stepHistory: StepHistory): Promise { + await ServerUtils.query( + this.options, + 'post', + ROUTES.updateStepExecution(runId), + {}, + stepHistory, + ); + } + + async getCollectionRef(collectionName: string): Promise { + return ServerUtils.query( + this.options, + 'get', + ROUTES.collectionRef(collectionName), + ); + } + + async getMcpServerConfigs(): Promise { + return ServerUtils.query(this.options, 'get', ROUTES.mcpServerConfigs); + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index c434071d83..2918b36c45 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -42,3 +42,4 @@ export { 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'; +export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index c36ea41d8e..93951f6f02 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; getCollectionRef(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 new file mode 100644 index 0000000000..ff37147e74 --- /dev/null +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -0,0 +1,105 @@ +import type { PendingStepExecution } from '../../src/types/execution'; +import type { CollectionRef } from '../../src/types/record'; +import type { StepHistory } from '../../src/types/step-history'; + +import { ServerUtils } from '@forestadmin/forestadmin-client'; + +import ForestServerWorkflowPort from '../../src/adapters/forest-server-workflow-port'; + +jest.mock('@forestadmin/forestadmin-client', () => ({ + ServerUtils: { query: jest.fn() }, +})); + +const mockQuery = ServerUtils.query as jest.Mock; + +const options = { envSecret: 'env-secret-123', forestServerUrl: 'https://api.forestadmin.com' }; + +describe('ForestServerWorkflowPort', () => { + let port: ForestServerWorkflowPort; + + beforeEach(() => { + jest.clearAllMocks(); + port = new ForestServerWorkflowPort(options); + }); + + describe('getPendingStepExecutions', () => { + it('should call the pending step executions route', async () => { + const pending: PendingStepExecution[] = []; + mockQuery.mockResolvedValue(pending); + + const result = await port.getPendingStepExecutions(); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/v1/workflow-step-executions/pending', + ); + expect(result).toBe(pending); + }); + }); + + describe('updateStepExecution', () => { + it('should post step history to the complete route', async () => { + mockQuery.mockResolvedValue(undefined); + const stepHistory: StepHistory = { + type: 'condition', + stepId: 'step-1', + stepIndex: 0, + status: 'success', + selectedOption: 'optionA', + }; + + await port.updateStepExecution('run-42', stepHistory); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'post', + '/liana/v1/workflow-step-executions/run-42/complete', + {}, + stepHistory, + ); + }); + }); + + describe('getCollectionRef', () => { + it('should fetch the collection ref by name', async () => { + const collectionRef: CollectionRef = { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [], + actions: [], + }; + mockQuery.mockResolvedValue(collectionRef); + + const result = await port.getCollectionRef('users'); + + expect(mockQuery).toHaveBeenCalledWith(options, 'get', '/liana/v1/collections/users'); + expect(result).toEqual(collectionRef); + }); + }); + + describe('getMcpServerConfigs', () => { + it('should fetch mcp server configs', async () => { + const configs = [{ name: 'mcp-1' }]; + mockQuery.mockResolvedValue(configs); + + const result = await port.getMcpServerConfigs(); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/mcp-server-configs-with-details', + ); + expect(result).toEqual(configs); + }); + }); + + describe('error propagation', () => { + it('should propagate errors from ServerUtils.query', async () => { + mockQuery.mockRejectedValue(new Error('Network error')); + + await expect(port.getPendingStepExecutions()).rejects.toThrow('Network error'); + }); + }); +});