diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d161736ea6..12c03a54e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,6 +80,7 @@ jobs: - plugin-aws-s3 - plugin-export-advanced - plugin-flattener + - workflow-executor steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/packages/ai-proxy/src/ai-client.ts b/packages/ai-proxy/src/ai-client.ts new file mode 100644 index 0000000000..4a3beeda53 --- /dev/null +++ b/packages/ai-proxy/src/ai-client.ts @@ -0,0 +1,65 @@ +import type { McpConfiguration } from './mcp-client'; +import type { AiConfiguration } from './provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; + +import { createBaseChatModel } from './create-base-chat-model'; +import { AINotConfiguredError } from './errors'; +import getAiConfiguration from './get-ai-configuration'; +import McpClient from './mcp-client'; +import validateAiConfigurations from './validate-ai-configurations'; + +// eslint-disable-next-line import/prefer-default-export +export class AiClient { + private readonly aiConfigurations: AiConfiguration[]; + private readonly logger?: Logger; + private readonly modelCache = new Map(); + private mcpClient?: McpClient; + + constructor(params?: { aiConfigurations?: AiConfiguration[]; logger?: Logger }) { + this.aiConfigurations = params?.aiConfigurations ?? []; + this.logger = params?.logger; + + validateAiConfigurations(this.aiConfigurations); + } + + getModel(aiName?: string): BaseChatModel { + const config = getAiConfiguration(this.aiConfigurations, aiName, this.logger); + if (!config) throw new AINotConfiguredError(); + + const cached = this.modelCache.get(config.name); + if (cached) return cached; + + const model = createBaseChatModel(config); + this.modelCache.set(config.name, model); + + return model; + } + + async loadRemoteTools(mcpConfig: McpConfiguration): Promise { + await this.closeMcpClient('Error closing previous MCP connection'); + + const newClient = new McpClient(mcpConfig, this.logger); + const tools = await newClient.loadTools(); + this.mcpClient = newClient; + + return tools; + } + + async closeConnections(): Promise { + await this.closeMcpClient('Error during MCP connection cleanup'); + } + + private async closeMcpClient(errorMessage: string): Promise { + if (!this.mcpClient) return; + + try { + await this.mcpClient.closeConnections(); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger?.('Error', errorMessage, err); + } finally { + this.mcpClient = undefined; + } + } +} diff --git a/packages/ai-proxy/src/create-base-chat-model.ts b/packages/ai-proxy/src/create-base-chat-model.ts new file mode 100644 index 0000000000..78d97ea5d1 --- /dev/null +++ b/packages/ai-proxy/src/create-base-chat-model.ts @@ -0,0 +1,26 @@ +import type { AiConfiguration } from './provider'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; + +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatOpenAI } from '@langchain/openai'; + +import { AIBadRequestError } from './errors'; + +// eslint-disable-next-line import/prefer-default-export +export function createBaseChatModel(config: AiConfiguration): BaseChatModel { + if (config.provider === 'openai') { + const { provider, name, ...opts } = config; + + return new ChatOpenAI({ maxRetries: 0, ...opts }); + } + + if (config.provider === 'anthropic') { + const { provider, name, model, ...opts } = config; + + return new ChatAnthropic({ maxRetries: 0, ...opts, model }); + } + + throw new AIBadRequestError( + `Unsupported AI provider '${(config as { provider: string }).provider}'.`, + ); +} diff --git a/packages/ai-proxy/src/get-ai-configuration.ts b/packages/ai-proxy/src/get-ai-configuration.ts new file mode 100644 index 0000000000..ee43acb5b6 --- /dev/null +++ b/packages/ai-proxy/src/get-ai-configuration.ts @@ -0,0 +1,28 @@ +import type { AiConfiguration } from './provider'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +export default function getAiConfiguration( + aiConfigurations: AiConfiguration[], + aiName?: string, + logger?: Logger, +): AiConfiguration | null { + if (aiConfigurations.length === 0) return null; + + if (aiName) { + const config = aiConfigurations.find(c => c.name === aiName); + + if (!config) { + const fallback = aiConfigurations[0]; + logger?.( + 'Warn', + `AI configuration '${aiName}' not found. Falling back to '${fallback.name}' (provider: ${fallback.provider}, model: ${fallback.model})`, + ); + + return fallback; + } + + return config; + } + + return aiConfigurations[0]; +} diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index 5dd913d5d4..c355e0f9bb 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -3,8 +3,10 @@ import type { McpConfiguration } from './mcp-client'; import McpConfigChecker from './mcp-config-checker'; export { createAiProvider } from './create-ai-provider'; +export { createBaseChatModel } from './create-base-chat-model'; export { default as ProviderDispatcher } from './provider-dispatcher'; export * from './provider-dispatcher'; +export * from './ai-client'; export * from './remote-tools'; export * from './router'; export * from './mcp-client'; diff --git a/packages/ai-proxy/src/router.ts b/packages/ai-proxy/src/router.ts index 8424a35bcb..807885ab76 100644 --- a/packages/ai-proxy/src/router.ts +++ b/packages/ai-proxy/src/router.ts @@ -5,12 +5,13 @@ import type { RouteArgs } from './schemas/route'; import type { Logger } from '@forestadmin/datasource-toolkit'; import type { z } from 'zod'; -import { AIBadRequestError, AIModelNotSupportedError } from './errors'; +import { AIBadRequestError } from './errors'; +import getAiConfiguration from './get-ai-configuration'; import McpClient from './mcp-client'; import ProviderDispatcher from './provider-dispatcher'; import { RemoteTools } from './remote-tools'; import { routeArgsSchema } from './schemas/route'; -import isModelSupportingTools from './supported-models'; +import validateAiConfigurations from './validate-ai-configurations'; export type { AiQueryArgs, @@ -40,15 +41,7 @@ export class Router { this.localToolsApiKeys = params?.localToolsApiKeys; this.logger = params?.logger; - this.validateConfigurations(); - } - - private validateConfigurations(): void { - for (const config of this.aiConfigurations) { - if (!isModelSupportingTools(config.model, config.provider)) { - throw new AIModelNotSupportedError(config.model); - } - } + validateAiConfigurations(this.aiConfigurations); } /** @@ -82,7 +75,11 @@ export class Router { switch (validatedArgs.route) { case 'ai-query': { - const aiConfiguration = this.getAiConfiguration(validatedArgs.query?.['ai-name']); + const aiConfiguration = getAiConfiguration( + this.aiConfigurations, + validatedArgs.query?.['ai-name'], + this.logger, + ); return await new ProviderDispatcher(aiConfiguration, remoteTools).dispatch( validatedArgs.body, @@ -141,26 +138,4 @@ export class Router { }) .join('; '); } - - private getAiConfiguration(aiName?: string): AiConfiguration | null { - if (this.aiConfigurations.length === 0) return null; - - if (aiName) { - const config = this.aiConfigurations.find(c => c.name === aiName); - - if (!config) { - const fallback = this.aiConfigurations[0]; - this.logger?.( - 'Warn', - `AI configuration '${aiName}' not found. Falling back to '${fallback.name}' (provider: ${fallback.provider}, model: ${fallback.model})`, - ); - - return fallback; - } - - return config; - } - - return this.aiConfigurations[0]; - } } diff --git a/packages/ai-proxy/src/validate-ai-configurations.ts b/packages/ai-proxy/src/validate-ai-configurations.ts new file mode 100644 index 0000000000..150234207e --- /dev/null +++ b/packages/ai-proxy/src/validate-ai-configurations.ts @@ -0,0 +1,12 @@ +import type { AiConfiguration } from './provider'; + +import { AIModelNotSupportedError } from './errors'; +import isModelSupportingTools from './supported-models'; + +export default function validateAiConfigurations(aiConfigurations: AiConfiguration[]): void { + for (const config of aiConfigurations) { + if (!isModelSupportingTools(config.model, config.provider)) { + throw new AIModelNotSupportedError(config.model); + } + } +} diff --git a/packages/ai-proxy/test/ai-client.test.ts b/packages/ai-proxy/test/ai-client.test.ts new file mode 100644 index 0000000000..6c6929dd2c --- /dev/null +++ b/packages/ai-proxy/test/ai-client.test.ts @@ -0,0 +1,413 @@ +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; + +import { AIModelNotSupportedError, AINotConfiguredError, AiClient } from '../src'; +import McpClient from '../src/mcp-client'; + +jest.mock('../src/mcp-client', () => { + return jest.fn().mockImplementation(() => ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn(), + })); +}); + +const MockedMcpClient = McpClient as jest.MockedClass; + +const createBaseChatModelMock = jest.fn().mockReturnValue({} as BaseChatModel); +jest.mock('../src/create-base-chat-model', () => ({ + createBaseChatModel: (...args: unknown[]) => createBaseChatModelMock(...args), +})); + +describe('Model validation', () => { + it('throws AIModelNotSupportedError for unsupported models', () => { + expect( + () => + new AiClient({ + aiConfigurations: [ + { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4' }, + ], + }), + ).toThrow(AIModelNotSupportedError); + }); + + it('accepts supported models', () => { + expect( + () => + new AiClient({ + aiConfigurations: [ + { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, + ], + }), + ).not.toThrow(); + }); +}); + +describe('getModel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns a BaseChatModel by calling createBaseChatModel', () => { + const fakeModel = { fake: true } as unknown as BaseChatModel; + createBaseChatModelMock.mockReturnValue(fakeModel); + + const client = new AiClient({ + aiConfigurations: [{ name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }], + }); + + const result = client.getModel('gpt4'); + + expect(createBaseChatModelMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'gpt4', provider: 'openai', model: 'gpt-4o' }), + ); + expect(result).toBe(fakeModel); + }); + + it('returns cached instance on second call with same name', () => { + const fakeModel = { fake: true } as unknown as BaseChatModel; + createBaseChatModelMock.mockReturnValue(fakeModel); + + const client = new AiClient({ + aiConfigurations: [{ name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }], + }); + + const first = client.getModel('gpt4'); + const second = client.getModel('gpt4'); + + expect(first).toBe(second); + expect(createBaseChatModelMock).toHaveBeenCalledTimes(1); + }); + + it('uses first configuration when aiName is not provided', () => { + const fakeModel = { fake: true } as unknown as BaseChatModel; + createBaseChatModelMock.mockReturnValue(fakeModel); + + const client = new AiClient({ + aiConfigurations: [ + { name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, + { name: 'gpt4mini', provider: 'openai', apiKey: 'dev', model: 'gpt-4o-mini' }, + ], + }); + + client.getModel(); + + expect(createBaseChatModelMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'gpt4', model: 'gpt-4o' }), + ); + }); + + it('throws AINotConfiguredError when aiConfigurations is empty', () => { + const client = new AiClient({}); + + expect(() => client.getModel()).toThrow(AINotConfiguredError); + }); + + it('throws AINotConfiguredError when constructed with no arguments', () => { + const client = new AiClient(); + + expect(() => client.getModel()).toThrow(AINotConfiguredError); + }); + + it('creates separate cached instances for different AI names', () => { + const fakeModel1 = { id: 1 } as unknown as BaseChatModel; + const fakeModel2 = { id: 2 } as unknown as BaseChatModel; + createBaseChatModelMock.mockReturnValueOnce(fakeModel1).mockReturnValueOnce(fakeModel2); + + const client = new AiClient({ + aiConfigurations: [ + { name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, + { name: 'gpt4mini', provider: 'openai', apiKey: 'dev', model: 'gpt-4o-mini' }, + ], + }); + + const result1 = client.getModel('gpt4'); + const result2 = client.getModel('gpt4mini'); + + expect(result1).not.toBe(result2); + expect(createBaseChatModelMock).toHaveBeenCalledTimes(2); + }); + + it('falls back to first config and caches by resolved name', () => { + const fakeModel = { fake: true } as unknown as BaseChatModel; + createBaseChatModelMock.mockReturnValue(fakeModel); + const mockLogger = jest.fn(); + + const client = new AiClient({ + aiConfigurations: [{ name: 'gpt4', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }], + logger: mockLogger, + }); + + const result = client.getModel('non-existent'); + + expect(mockLogger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining("AI configuration 'non-existent' not found"), + ); + expect(createBaseChatModelMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'gpt4' }), + ); + expect(result).toBe(fakeModel); + }); +}); + +describe('loadRemoteTools', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates an McpClient and returns loaded tools', async () => { + const fakeTools = [{ name: 'tool1' }]; + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue(fakeTools), + closeConnections: jest.fn(), + } as unknown as McpClient), + ); + + const client = new AiClient({}); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + const result = await client.loadRemoteTools(mcpConfig); + + expect(MockedMcpClient).toHaveBeenCalledWith(mcpConfig, undefined); + expect(result).toBe(fakeTools); + }); + + it('closes previous client before creating a new one', async () => { + const closeConnectionsMock1 = jest.fn(); + const closeConnectionsMock2 = jest.fn(); + + jest + .mocked(McpClient) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: closeConnectionsMock1, + } as unknown as McpClient), + ) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: closeConnectionsMock2, + } as unknown as McpClient), + ); + + const client = new AiClient({}); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + await client.loadRemoteTools(mcpConfig); + await client.loadRemoteTools(mcpConfig); + + expect(closeConnectionsMock1).toHaveBeenCalledTimes(1); + expect(MockedMcpClient).toHaveBeenCalledTimes(2); + }); + + it('passes the logger to McpClient', async () => { + const customLogger: Logger = jest.fn(); + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn(), + } as unknown as McpClient), + ); + + const client = new AiClient({ logger: customLogger }); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + await client.loadRemoteTools(mcpConfig); + + expect(MockedMcpClient).toHaveBeenCalledWith(mcpConfig, customLogger); + }); + + it('still creates a new client when closing the previous one fails', async () => { + const mockLogger = jest.fn(); + const closeError = new Error('Close failed'); + const fakeTools = [{ name: 'tool1' }]; + + jest + .mocked(McpClient) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue(closeError), + } as unknown as McpClient), + ) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue(fakeTools), + closeConnections: jest.fn(), + } as unknown as McpClient), + ); + + const client = new AiClient({ logger: mockLogger }); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + await client.loadRemoteTools(mcpConfig); + const result = await client.loadRemoteTools(mcpConfig); + + expect(result).toBe(fakeTools); + expect(MockedMcpClient).toHaveBeenCalledTimes(2); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error closing previous MCP connection', + closeError, + ); + }); + + it('wraps non-Error thrown values when closing previous client fails', async () => { + const mockLogger = jest.fn(); + + jest + .mocked(McpClient) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue('string error'), + } as unknown as McpClient), + ) + .mockImplementationOnce( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn(), + } as unknown as McpClient), + ); + + const client = new AiClient({ logger: mockLogger }); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + await client.loadRemoteTools(mcpConfig); + await client.loadRemoteTools(mcpConfig); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error closing previous MCP connection', + expect.objectContaining({ message: 'string error' }), + ); + }); + + it('does not store mcpClient reference when loadTools fails', async () => { + const loadToolsError = new Error('loadTools failed'); + + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockRejectedValue(loadToolsError), + closeConnections: jest.fn(), + } as unknown as McpClient), + ); + + const client = new AiClient({}); + const mcpConfig = { configs: { server1: { command: 'test', args: [] } } }; + + await expect(client.loadRemoteTools(mcpConfig)).rejects.toThrow(loadToolsError); + + // closeConnections should be a no-op since mcpClient was never stored + await expect(client.closeConnections()).resolves.toBeUndefined(); + }); +}); + +describe('closeConnections', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('closes the McpClient', async () => { + const closeConnectionsMock = jest.fn(); + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: closeConnectionsMock, + } as unknown as McpClient), + ); + + const client = new AiClient({}); + await client.loadRemoteTools({ configs: { server1: { command: 'test', args: [] } } }); + + await client.closeConnections(); + + expect(closeConnectionsMock).toHaveBeenCalledTimes(1); + }); + + it('is a no-op when no McpClient exists', async () => { + const client = new AiClient({}); + + await expect(client.closeConnections()).resolves.toBeUndefined(); + }); + + it('logs error and clears reference when closeConnections throws', async () => { + const mockLogger = jest.fn(); + const closeError = new Error('close failed'); + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue(closeError), + } as unknown as McpClient), + ); + + const client = new AiClient({ logger: mockLogger }); + await client.loadRemoteTools({ configs: { server1: { command: 'test', args: [] } } }); + + // Should not throw — error is caught and logged + await client.closeConnections(); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error during MCP connection cleanup', + closeError, + ); + + // Second call should be a no-op (reference cleared in finally block) + await expect(client.closeConnections()).resolves.toBeUndefined(); + }); + + it('wraps non-Error thrown values during cleanup', async () => { + const mockLogger = jest.fn(); + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockRejectedValue('string error'), + } as unknown as McpClient), + ); + + const client = new AiClient({ logger: mockLogger }); + await client.loadRemoteTools({ configs: { server1: { command: 'test', args: [] } } }); + + await client.closeConnections(); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Error during MCP connection cleanup', + expect.objectContaining({ message: 'string error' }), + ); + }); + + it('is safe to call twice', async () => { + const closeConnectionsMock = jest.fn(); + jest.mocked(McpClient).mockImplementation( + () => + ({ + loadTools: jest.fn().mockResolvedValue([]), + closeConnections: closeConnectionsMock, + } as unknown as McpClient), + ); + + const client = new AiClient({}); + await client.loadRemoteTools({ configs: { server1: { command: 'test', args: [] } } }); + + await client.closeConnections(); + await client.closeConnections(); + + expect(closeConnectionsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ai-proxy/test/create-base-chat-model.test.ts b/packages/ai-proxy/test/create-base-chat-model.test.ts new file mode 100644 index 0000000000..6da3e41af3 --- /dev/null +++ b/packages/ai-proxy/test/create-base-chat-model.test.ts @@ -0,0 +1,118 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { ChatOpenAI } from '@langchain/openai'; + +import { createBaseChatModel } from '../src/create-base-chat-model'; +import { AIBadRequestError } from '../src/errors'; + +jest.mock('@langchain/openai', () => ({ + ChatOpenAI: jest.fn(), +})); + +jest.mock('@langchain/anthropic', () => ({ + ChatAnthropic: jest.fn(), +})); + +describe('createBaseChatModel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a ChatOpenAI for openai provider with maxRetries: 0', () => { + const config = { + name: 'gpt4', + provider: 'openai' as const, + apiKey: 'test-key', + model: 'gpt-4o', + }; + + createBaseChatModel(config); + + expect(ChatOpenAI).toHaveBeenCalledWith({ + maxRetries: 0, + apiKey: 'test-key', + model: 'gpt-4o', + }); + }); + + it('forwards extra options like temperature to ChatOpenAI', () => { + const config = { + name: 'gpt4', + provider: 'openai' as const, + apiKey: 'test-key', + model: 'gpt-4o', + temperature: 0.7, + }; + + createBaseChatModel(config); + + expect(ChatOpenAI).toHaveBeenCalledWith({ + maxRetries: 0, + apiKey: 'test-key', + model: 'gpt-4o', + temperature: 0.7, + }); + }); + + it('forwards extra options like temperature to ChatAnthropic', () => { + const config = { + name: 'claude', + provider: 'anthropic' as const, + apiKey: 'test-key', + model: 'claude-3-5-sonnet-latest' as const, + temperature: 0.5, + }; + + createBaseChatModel(config); + + expect(ChatAnthropic).toHaveBeenCalledWith({ + maxRetries: 0, + apiKey: 'test-key', + model: 'claude-3-5-sonnet-latest', + temperature: 0.5, + }); + }); + + it('does not pass __includeRawResponse for openai provider', () => { + const config = { + name: 'gpt4', + provider: 'openai' as const, + apiKey: 'test-key', + model: 'gpt-4o', + }; + + createBaseChatModel(config); + + const passedArgs = (ChatOpenAI as unknown as jest.Mock).mock.calls[0][0]; + expect(passedArgs).not.toHaveProperty('__includeRawResponse'); + }); + + it('creates a ChatAnthropic for anthropic provider with maxRetries: 0', () => { + const config = { + name: 'claude', + provider: 'anthropic' as const, + apiKey: 'test-key', + model: 'claude-3-5-sonnet-latest' as const, + }; + + createBaseChatModel(config); + + expect(ChatAnthropic).toHaveBeenCalledWith({ + maxRetries: 0, + apiKey: 'test-key', + model: 'claude-3-5-sonnet-latest', + }); + }); + + it('throws AIBadRequestError for unsupported provider', () => { + const config = { + name: 'unknown', + provider: 'unknown-provider' as any, + model: 'some-model', + }; + + expect(() => createBaseChatModel(config)).toThrow(AIBadRequestError); + expect(() => createBaseChatModel(config)).toThrow( + "Unsupported AI provider 'unknown-provider'.", + ); + }); +}); diff --git a/packages/ai-proxy/test/get-ai-configuration.test.ts b/packages/ai-proxy/test/get-ai-configuration.test.ts new file mode 100644 index 0000000000..aee9bf2d39 --- /dev/null +++ b/packages/ai-proxy/test/get-ai-configuration.test.ts @@ -0,0 +1,57 @@ +import type { AiConfiguration } from '../src/provider'; + +import getAiConfiguration from '../src/get-ai-configuration'; + +const gpt4Config: AiConfiguration = { + name: 'gpt4', + provider: 'openai', + apiKey: 'dev', + model: 'gpt-4o', +}; + +const claudeConfig: AiConfiguration = { + name: 'claude', + provider: 'anthropic', + apiKey: 'dev', + model: 'claude-3-5-sonnet-latest', +}; + +describe('getAiConfiguration', () => { + it('returns null when aiConfigurations is empty', () => { + expect(getAiConfiguration([], 'gpt4')).toBeNull(); + }); + + it('returns null when aiConfigurations is empty and no name provided', () => { + expect(getAiConfiguration([])).toBeNull(); + }); + + it('returns the matching config when aiName matches', () => { + expect(getAiConfiguration([gpt4Config, claudeConfig], 'claude')).toBe(claudeConfig); + }); + + it('returns first config when aiName is not provided', () => { + expect(getAiConfiguration([gpt4Config, claudeConfig])).toBe(gpt4Config); + }); + + it('returns first config when aiName is undefined', () => { + expect(getAiConfiguration([gpt4Config, claudeConfig], undefined)).toBe(gpt4Config); + }); + + it('falls back to first config and logs warning when aiName not found', () => { + const logger = jest.fn(); + + const result = getAiConfiguration([gpt4Config, claudeConfig], 'non-existent', logger); + + expect(result).toBe(gpt4Config); + expect(logger).toHaveBeenCalledWith( + 'Warn', + "AI configuration 'non-existent' not found. Falling back to 'gpt4' (provider: openai, model: gpt-4o)", + ); + }); + + it('does not crash when logger is undefined and aiName not found', () => { + const result = getAiConfiguration([gpt4Config], 'non-existent'); + + expect(result).toBe(gpt4Config); + }); +}); 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/CHANGELOG.md b/packages/workflow-executor/CHANGELOG.md new file mode 100644 index 0000000000..5d9c4c1989 --- /dev/null +++ b/packages/workflow-executor/CHANGELOG.md @@ -0,0 +1 @@ +# @forestadmin/workflow-executor diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md new file mode 100644 index 0000000000..2be0522bca --- /dev/null +++ b/packages/workflow-executor/CLAUDE.md @@ -0,0 +1,84 @@ +# @forestadmin/workflow-executor + +> **Note to Claude**: Keep this file up to date. When adding a new feature, module, architectural pattern, or dependency, update the relevant section below. + +## Overview + +TypeScript library (framework-agnostic) that executes workflow steps on the client's infrastructure, alongside the Forest Admin agent. The orchestrator never sees client data — it only sends step definitions; this package fetches them and runs them locally. + +## Why this package exists — Frontend → Backend migration + +Workflows currently run entirely in the **frontend** (`forestadmin/frontend`). The front parses BPMN, manages the run state machine, calls the AI, executes tools, and handles user interactions — all in the browser. + +This works for interactive use cases but blocks **automation**: scheduled workflows, API-triggered runs, and headless execution all require a human with a browser open. The goal of this migration is to move workflow execution to the **backend** (client-side agent infrastructure) so workflows can run without a frontend and without human intervention. + +### What stays on the front +- Workflow designer (BPMN editor) +- Run monitoring / progress display +- Manual decisions when the AI can't decide (`manual-decision` status) + +### What moves to the backend (this package) +- Step execution (condition decisions, AI tasks, record operations) +- AI calls (gateway option selection, tool selection, tool execution) +- Record selection and data access (via AgentPort) + +### Constraint: must be ISO with front +The executor must produce the same behavior as the frontend implementation (`forestadmin/frontend`, `app/features/workflow/`). Same tool schemas, same AI interactions, same fallback logic. + +## System Architecture + +The workflow system is split into 4 components: + +- **Front** — The Forest Admin UI. Users design workflows (sequence of steps) and trigger runs. Displays run progress and results in real time. +- **Orchestrator** — Forest Admin backend. Stores workflow definitions, manages run state machines, and dispatches steps. Never sees client data — only step metadata. +- **Executor** _(this package)_ — Runs on the client's infrastructure. Polls the orchestrator for pending steps, executes them locally (with access to client data), and reports results back. Privacy boundary lives here. +- **Agent** — The Forest Admin agent (`@forestadmin/agent`). Acts as a proxy for the executor — provides access to the datasource layer (collections, actions, fields) so the executor can read/write client data without direct database access. + +``` +Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor ──▶ Agent (datasources) +``` + +## Package Structure + +``` +src/ +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError +├── types/ # Core type definitions (@draft) +│ ├── step-definition.ts # StepType enum + step definition interfaces +│ ├── step-history.ts # Step outcome tracking types +│ ├── step-execution-data.ts # Runtime state for in-progress steps +│ ├── record.ts # Record references and data types +│ └── execution.ts # Top-level execution types (context, results) +├── ports/ # IO boundary interfaces (@draft) +│ ├── agent-port.ts # Interface to the Forest Admin agent (datasource) +│ ├── workflow-port.ts # Interface to the orchestrator +│ └── run-store.ts # Interface for persisting run state (scoped to a run) +├── executors/ # Step executor implementations +│ ├── base-step-executor.ts # Abstract base class (context injection + shared helpers) +│ └── condition-step-executor.ts # AI-powered condition step (chooses among options) +└── index.ts # Barrel exports +``` + +## Architecture Principles + +- **Pull-based** — The executor polls for pending steps via a port interface (`WorkflowPort.getPendingStepExecutions`; polling loop not yet implemented). +- **Atomic** — Each step executes in isolation. A run store (scoped per run) maintains continuity between steps. +- **Privacy** — Zero client data leaves the client's infrastructure. `StepHistory` is sent to the orchestrator and must NEVER contain client data. Privacy-sensitive information (e.g. AI reasoning) must stay in `StepExecutionData` (persisted in the RunStore, client-side only). +- **Ports (IO injection)** — All external IO goes through injected port interfaces, keeping the core pure and testable. +- **AI integration** — Uses `@langchain/core` (`BaseChatModel`, `DynamicStructuredTool`) for AI-powered steps. `ExecutionContext.model` is a `BaseChatModel`. +- **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. + +## Commands + +```bash +yarn workspace @forestadmin/workflow-executor build # Build +yarn workspace @forestadmin/workflow-executor test # Run tests +yarn workspace @forestadmin/workflow-executor lint # Lint +``` + +## Testing + +- Prefer integration tests over unit tests +- Use AAA pattern (Arrange, Act, Assert) +- Test behavior, not implementation +- Strong assertions: verify exact arguments, not just that a function was called diff --git a/packages/workflow-executor/LICENSE b/packages/workflow-executor/LICENSE new file mode 100644 index 0000000000..e62ec04cde --- /dev/null +++ b/packages/workflow-executor/LICENSE @@ -0,0 +1,674 @@ +GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/packages/workflow-executor/jest.config.ts b/packages/workflow-executor/jest.config.ts new file mode 100644 index 0000000000..d622773e8a --- /dev/null +++ b/packages/workflow-executor/jest.config.ts @@ -0,0 +1,8 @@ +/* eslint-disable import/no-relative-packages */ +import jestConfig from '../../jest.config'; + +export default { + ...jestConfig, + collectCoverageFrom: ['/src/**/*.ts'], + testMatch: ['/test/**/*.test.ts'], +}; diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json new file mode 100644 index 0000000000..3138b9a5d9 --- /dev/null +++ b/packages/workflow-executor/package.json @@ -0,0 +1,31 @@ +{ + "name": "@forestadmin/workflow-executor", + "version": "1.0.0", + "main": "dist/index.js", + "license": "GPL-3.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ForestAdmin/agent-nodejs.git", + "directory": "packages/workflow-executor" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "clean": "rm -rf coverage dist", + "lint": "eslint src test", + "test": "jest" + }, + "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/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts new file mode 100644 index 0000000000..23015e8bcd --- /dev/null +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -0,0 +1,129 @@ +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( + primaryKeyFields: string[], + recordId: Array, +): SelectOptions['filters'] { + if (primaryKeyFields.length === 1) { + return { field: primaryKeyFields[0], operator: 'Equal', value: recordId[0] }; + } + + return { + aggregator: 'And', + 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 encodePk(recordId: Array): string { + return recordId.map(v => String(v)).join('|'); +} + +function extractRecordId( + primaryKeyFields: string[], + record: Record, +): Array { + return primaryKeyFields.map(field => record[field] as string | number); +} + +export default class AgentClientAgentPort implements AgentPort { + private readonly client: RemoteAgentClient; + private readonly collectionRefs: Record; + + constructor(params: { + client: RemoteAgentClient; + collectionRefs: Record; + }) { + this.client = params.client; + this.collectionRefs = params.collectionRefs; + } + + async getRecord(collectionName: string, recordId: Array): Promise { + const ref = this.getCollectionRef(collectionName); + const records = await this.client.collection(collectionName).list>({ + filters: buildPkFilter(ref.primaryKeyFields, recordId), + pagination: { size: 1, number: 1 }, + }); + + if (records.length === 0) { + throw new RecordNotFoundError(collectionName, encodePk(recordId)); + } + + return { ...ref, recordId, values: records[0] }; + } + + async updateRecord( + collectionName: string, + recordId: Array, + values: Record, + ): Promise { + const ref = this.getCollectionRef(collectionName); + const updatedRecord = await this.client + .collection(collectionName) + .update>(encodePk(recordId), values); + + return { ...ref, recordId, values: updatedRecord }; + } + + async getRelatedData( + collectionName: string, + recordId: Array, + relationName: string, + ): Promise { + const relatedRef = this.getCollectionRef(relationName); + + const records = await this.client + .collection(collectionName) + .relation(relationName, encodePk(recordId)) + .list>(); + + return records.map(record => ({ + ...relatedRef, + recordId: extractRecordId(relatedRef.primaryKeyFields, record), + values: record, + })); + } + + async getActions(collectionName: string): Promise { + const ref = this.collectionRefs[collectionName]; + + return ref ? ref.actions : []; + } + + async executeAction( + collectionName: string, + actionName: string, + recordIds: Array[], + ): Promise { + const encodedIds = recordIds.map(id => encodePk(id)); + const action = await this.client + .collection(collectionName) + .action(actionName, { recordIds: encodedIds }); + + return action.execute(); + } + + private getCollectionRef(collectionName: string): CollectionRef { + const ref = this.collectionRefs[collectionName]; + + if (!ref) { + return { + collectionName, + collectionDisplayName: collectionName, + primaryKeyFields: ['id'], + fields: [], + actions: [], + }; + } + + return ref; + } +} 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/errors.ts b/packages/workflow-executor/src/errors.ts new file mode 100644 index 0000000000..d735977d4f --- /dev/null +++ b/packages/workflow-executor/src/errors.ts @@ -0,0 +1,29 @@ +/* eslint-disable max-classes-per-file */ + +export class WorkflowExecutorError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class MissingToolCallError extends WorkflowExecutorError { + constructor() { + super('AI did not return a tool call'); + } +} + +export class MalformedToolCallError extends WorkflowExecutorError { + readonly toolName: string; + + constructor(toolName: string, details: string) { + super(`AI returned a malformed tool call for "${toolName}": ${details}`); + 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/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts new file mode 100644 index 0000000000..6c4fc76944 --- /dev/null +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -0,0 +1,106 @@ +import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { StepDefinition } from '../types/step-definition'; +import type { StepExecutionData } from '../types/step-execution-data'; +import type { StepHistory } from '../types/step-history'; +import type { AIMessage, BaseMessage } from '@langchain/core/messages'; +import type { DynamicStructuredTool } from '@langchain/core/tools'; + +import { SystemMessage } from '@langchain/core/messages'; + +import { MalformedToolCallError, MissingToolCallError } from '../errors'; + +export default abstract class BaseStepExecutor< + TStep extends StepDefinition = StepDefinition, + THistory extends StepHistory = StepHistory, +> { + protected readonly context: ExecutionContext; + + constructor(context: ExecutionContext) { + this.context = context; + } + + abstract execute(step: TStep, stepHistory: THistory): Promise; + + /** + * Returns a SystemMessage array summarizing previously executed steps. + * Empty array when there is no history. Ready to spread into a messages array. + */ + protected async buildPreviousStepsMessages(): Promise { + if (!this.context.history.length) return []; + + const summary = await this.summarizePreviousSteps(); + + return [new SystemMessage(summary)]; + } + + /** + * Builds a text summary of previously executed steps for AI prompts. + * Correlates history entries (step + stepHistory pairs) with executionParams + * from the RunStore (matched by stepHistory.stepIndex). + * When no executionParams is available, falls back to StepHistory details. + */ + private async summarizePreviousSteps(): Promise { + const allStepExecutions = await this.context.runStore.getStepExecutions(); + + return this.context.history + .map(({ step, stepHistory }) => { + const execution = allStepExecutions.find(e => e.stepIndex === stepHistory.stepIndex); + + return this.buildStepSummary(step, stepHistory, execution); + }) + .join('\n\n'); + } + + private buildStepSummary( + step: StepDefinition, + stepHistory: StepHistory, + execution: StepExecutionData | undefined, + ): string { + const prompt = step.prompt ?? '(no prompt)'; + const header = `Step "${step.id}" (index ${stepHistory.stepIndex}):`; + const lines = [header, ` Prompt: ${prompt}`]; + + if (execution?.executionParams) { + lines.push(` Result: ${JSON.stringify(execution.executionParams)}`); + } else { + const { stepId, stepIndex, type, ...historyDetails } = stepHistory; + lines.push(` History: ${JSON.stringify(historyDetails)}`); + } + + return lines.join('\n'); + } + + /** + * Binds a single tool to the model, invokes it, and extracts the tool call args. + * Throws MalformedToolCallError or MissingToolCallError on invalid AI responses. + */ + protected async invokeWithTool>( + messages: BaseMessage[], + tool: DynamicStructuredTool, + ): Promise { + const modelWithTool = this.context.model.bindTools([tool], { tool_choice: 'any' }); + const response = await modelWithTool.invoke(messages); + + return this.extractToolCallArgs(response); + } + + /** + * Extracts the first tool call's args from an AI response. + * Throws if the AI returned a malformed tool call (invalid_tool_calls) or no tool call at all. + */ + private extractToolCallArgs>(response: AIMessage): T { + const toolCall = response.tool_calls?.[0]; + if (toolCall?.args) return toolCall.args as T; + + const invalidCall = response.invalid_tool_calls?.[0]; + + if (invalidCall) { + throw new MalformedToolCallError( + invalidCall.name ?? 'unknown', + invalidCall.error ?? 'no details available', + ); + } + + throw new MissingToolCallError(); + } +} diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts new file mode 100644 index 0000000000..b90d47ad81 --- /dev/null +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -0,0 +1,101 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { ConditionStepDefinition } from '../types/step-definition'; +import type { ConditionStepHistory } from '../types/step-history'; + +import { HumanMessage, SystemMessage } from '@langchain/core/messages'; +import { DynamicStructuredTool } from '@langchain/core/tools'; +import { z } from 'zod'; + +import BaseStepExecutor from './base-step-executor'; + +interface GatewayToolArgs { + option: string | null; + reasoning: string; + question: string; +} + +const GATEWAY_SYSTEM_PROMPT = `You are an AI agent selecting the correct option for a workflow gateway decision. + +**Task**: Analyze the question and available options, then select the option that DIRECTLY answers the question. Options must be literal answers, not interpretations. + +**Critical Rule**: Options must semantically match possible answers to the question. +- Question "Does X contain Y?" expects options like "yes"/"no", NOT colors or unrelated values +- Question "What is the status?" expects options like "active"/"inactive", NOT arbitrary words +- If options don't match expected answer types, select null + +**NEVER invent mappings** between options and answers (e.g., never assume "purple"="no" or "red"="yes") + +**When to select null**: +- Options are semantically unrelated to the question type (colors for yes/no questions) +- None of the options literally match the expected answer +- The question is ambiguous or lacks necessary context +- You are less than 80% confident in any option + +**Reasoning format**: +- State which option you selected and why +- If selecting null: explain why options don't match the question +- Do not refer to yourself as "I" in the response, use a passive formulation instead.`; + +export default class ConditionStepExecutor extends BaseStepExecutor< + ConditionStepDefinition, + ConditionStepHistory +> { + async execute( + step: ConditionStepDefinition, + stepHistory: ConditionStepHistory, + ): Promise { + const tool = new DynamicStructuredTool({ + name: 'choose-gateway-option', + description: + 'Select the option that answers the question. ' + + 'Use null if no option matches or you are uncertain. ' + + 'Explain your reasoning.', + schema: z.object({ + reasoning: z.string().describe('The reasoning behind the choice'), + question: z.string().describe('The question to answer by choosing an option'), + option: z + .enum(step.options) + .nullable() + .describe('The chosen option, or null if no option clearly answers the question.'), + }), + func: async input => JSON.stringify(input), + }); + + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(GATEWAY_SYSTEM_PROMPT), + new HumanMessage(`**Question**: ${step.prompt ?? 'Choose the most appropriate option.'}`), + ]; + + let args: GatewayToolArgs; + + try { + args = await this.invokeWithTool(messages, tool); + } catch (error: unknown) { + return { + stepHistory: { + ...stepHistory, + status: 'error', + error: (error as Error).message, + }, + }; + } + + const { option: selectedOption, reasoning } = args; + + await this.context.runStore.saveStepExecution({ + type: 'condition', + stepIndex: stepHistory.stepIndex, + executionParams: { answer: selectedOption, reasoning }, + executionResult: selectedOption ? { answer: selectedOption } : undefined, + }); + + if (!selectedOption) { + return { stepHistory: { ...stepHistory, status: 'manual-decision' } }; + } + + return { + stepHistory: { ...stepHistory, status: 'success', selectedOption }, + }; + } +} diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts new file mode 100644 index 0000000000..2918b36c45 --- /dev/null +++ b/packages/workflow-executor/src/index.ts @@ -0,0 +1,45 @@ +export { StepType } from './types/step-definition'; +export type { + StepCategory, + ConditionStepDefinition, + AiTaskStepDefinition, + StepDefinition, +} from './types/step-definition'; + +export type { + StepStatus, + ConditionStepHistory, + AiTaskStepHistory, + StepHistory, +} from './types/step-history'; + +export type { + ConditionStepExecutionData, + AiTaskStepExecutionData, + StepExecutionData, +} from './types/step-execution-data'; + +export type { RecordFieldRef, ActionRef, CollectionRef, RecordData } from './types/record'; + +export type { + StepRecord, + UserInput, + PendingStepExecution, + StepExecutionResult, + ExecutionContext, +} from './types/execution'; + +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, + 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'; +export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts new file mode 100644 index 0000000000..6a588f1f23 --- /dev/null +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -0,0 +1,23 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import type { ActionRef, RecordData } from '../types/record'; + +export interface AgentPort { + getRecord(collectionName: string, recordId: Array): Promise; + updateRecord( + collectionName: string, + recordId: Array, + values: Record, + ): Promise; + getRelatedData( + collectionName: string, + recordId: Array, + relationName: string, + ): Promise; + getActions(collectionName: string): Promise; + executeAction( + collectionName: string, + actionName: string, + recordIds: Array[], + ): Promise; +} diff --git a/packages/workflow-executor/src/ports/run-store.ts b/packages/workflow-executor/src/ports/run-store.ts new file mode 100644 index 0000000000..212ab14088 --- /dev/null +++ b/packages/workflow-executor/src/ports/run-store.ts @@ -0,0 +1,13 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import type { RecordData } from '../types/record'; +import type { StepExecutionData } from '../types/step-execution-data'; + +export interface RunStore { + getRecords(): Promise; + getRecord(collectionName: string, recordId: string): Promise; + saveRecord(record: RecordData): Promise; + getStepExecutions(): Promise; + getStepExecution(stepIndex: number): Promise; + saveStepExecution(stepExecution: StepExecutionData): Promise; +} diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts new file mode 100644 index 0000000000..93951f6f02 --- /dev/null +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -0,0 +1,15 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import type { PendingStepExecution } from '../types/execution'; +import type { CollectionRef } from '../types/record'; +import type { StepHistory } from '../types/step-history'; + +/** Placeholder -- will be typed as McpConfiguration from @forestadmin/ai-proxy/mcp-client once added as dependency. */ +export type McpConfiguration = unknown; + +export interface WorkflowPort { + getPendingStepExecutions(): Promise; + updateStepExecution(runId: string, stepHistory: StepHistory): Promise; + getCollectionRef(collectionName: string): Promise; + getMcpServerConfigs(): Promise; +} diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts new file mode 100644 index 0000000000..d2524403cf --- /dev/null +++ b/packages/workflow-executor/src/types/execution.ts @@ -0,0 +1,39 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import type { CollectionRef } from './record'; +import type { StepDefinition } from './step-definition'; +import type { StepHistory } from './step-history'; +import type { AgentPort } from '../ports/agent-port'; +import type { RunStore } from '../ports/run-store'; +import type { WorkflowPort } from '../ports/workflow-port'; +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; + +export interface StepRecord { + step: StepDefinition; + stepHistory: StepHistory; +} + +export type UserInput = { type: 'confirmation'; confirmed: boolean }; + +export interface PendingStepExecution { + readonly runId: string; + readonly step: StepDefinition; + readonly stepHistory: StepHistory; + readonly previousSteps: ReadonlyArray; + readonly availableRecords: ReadonlyArray; + readonly userInput?: UserInput; +} + +export interface StepExecutionResult { + stepHistory: StepHistory; +} + +export interface ExecutionContext { + readonly runId: string; + readonly model: BaseChatModel; + readonly agentPort: AgentPort; + readonly workflowPort: WorkflowPort; + readonly runStore: RunStore; + readonly history: ReadonlyArray>; + readonly remoteTools: readonly unknown[]; +} diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts new file mode 100644 index 0000000000..14064fcb1f --- /dev/null +++ b/packages/workflow-executor/src/types/record.ts @@ -0,0 +1,27 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +export interface RecordFieldRef { + fieldName: string; + displayName: string; + type: string; + isRelationship: boolean; + referencedCollectionName?: string; +} + +export interface ActionRef { + name: string; + displayName: string; +} + +export interface CollectionRef { + collectionName: string; + collectionDisplayName: string; + primaryKeyFields: string[]; + fields: RecordFieldRef[]; + actions: ActionRef[]; +} + +export interface RecordData extends CollectionRef { + recordId: Array; + values: Record; +} diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts new file mode 100644 index 0000000000..dffae8c312 --- /dev/null +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -0,0 +1,42 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +export enum StepType { + Condition = 'condition', + ReadRecord = 'read-record', + UpdateRecord = 'update-record', + TriggerAction = 'trigger-action', + LoadRelatedRecord = 'load-related-record', +} + +interface BaseStepDefinition { + id: string; + type: StepType; + aiConfigName?: string; +} + +export interface ConditionStepDefinition extends BaseStepDefinition { + type: StepType.Condition; + options: [string, ...string[]]; + prompt?: string; +} + +export interface AiTaskStepDefinition extends BaseStepDefinition { + type: + | StepType.ReadRecord + | StepType.UpdateRecord + | StepType.TriggerAction + | StepType.LoadRelatedRecord; + recordSourceStepId?: string; + prompt?: string; + automaticCompletion?: boolean; + allowedTools?: string[]; + remoteToolsSourceId?: string; +} + +export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; + +/** + * Coarse categorization of steps. StepType has 5 fine-grained values; + * StepCategory collapses the 4 non-condition types into 'ai-task'. + */ +export type StepCategory = 'condition' | 'ai-task'; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts new file mode 100644 index 0000000000..e2d46eaf47 --- /dev/null +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -0,0 +1,23 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +import type { CollectionRef } from './record'; + +interface BaseStepExecutionData { + stepIndex: number; +} + +export interface ConditionStepExecutionData extends BaseStepExecutionData { + type: 'condition'; + executionParams?: { answer: string | null; reasoning?: string }; + executionResult?: { answer: string }; +} + +export interface AiTaskStepExecutionData extends BaseStepExecutionData { + type: 'ai-task'; + executionParams?: Record; + executionResult?: Record; + toolConfirmationInterruption?: Record; + selectedRecord?: CollectionRef; +} + +export type StepExecutionData = ConditionStepExecutionData | AiTaskStepExecutionData; diff --git a/packages/workflow-executor/src/types/step-history.ts b/packages/workflow-executor/src/types/step-history.ts new file mode 100644 index 0000000000..bf9b66b61a --- /dev/null +++ b/packages/workflow-executor/src/types/step-history.ts @@ -0,0 +1,38 @@ +/** @draft Types derived from the workflow-executor spec -- subject to change. */ + +type BaseStepStatus = 'success' | 'error'; + +/** Condition steps can fall back to human decision when the AI is uncertain. */ +export type ConditionStepStatus = BaseStepStatus | 'manual-decision'; + +/** AI task steps can pause mid-execution to await user input (e.g. tool confirmation). */ +export type AiTaskStepStatus = BaseStepStatus | 'awaiting-input'; + +/** Union of all step statuses. */ +export type StepStatus = ConditionStepStatus | AiTaskStepStatus; + +/** + * StepHistory is sent to the orchestrator — it must NEVER contain client data. + * Any privacy-sensitive information (e.g. AI reasoning) must stay in + * StepExecutionData (persisted in the RunStore, client-side only). + */ +interface BaseStepHistory { + stepId: string; + stepIndex: number; + /** Present when status is 'error'. */ + error?: string; +} + +export interface ConditionStepHistory extends BaseStepHistory { + type: 'condition'; + status: ConditionStepStatus; + /** Present when status is 'success'. */ + selectedOption?: string; +} + +export interface AiTaskStepHistory extends BaseStepHistory { + type: 'ai-task'; + status: AiTaskStepStatus; +} + +export type StepHistory = ConditionStepHistory | AiTaskStepHistory; 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 0000000000..8789907875 --- /dev/null +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -0,0 +1,241 @@ +import type { CollectionRef } from '../../src/types/record'; +import type { 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() }; + const mockRelation = { list: jest.fn() }; + const mockCollection = { + list: jest.fn(), + update: 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 collectionRefs: Record; + let port: AgentClientAgentPort; + + beforeEach(() => { + jest.clearAllMocks(); + + ({ client, mockCollection, mockRelation, mockAction } = createMockClient()); + + collectionRefs = { + users: { + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: [ + { 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' }, + ], + }, + 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 }, + ], + actions: [], + }, + }; + + port = new AgentClientAgentPort({ client, collectionRefs }); + }); + + describe('getRecord', () => { + it('should return a RecordData for a simple PK', async () => { + mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); + + 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: [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 And filter for composite PKs', 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([]); + + 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', [1]); + + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { field: 'id', operator: 'Equal', value: 1 }, + }), + ); + expect(result.collectionName).toBe('unknown'); + expect(result.fields).toEqual([]); + }); + }); + + describe('updateRecord', () => { + 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' }); + + expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); + expect(result).toEqual({ + recordId: [42], + collectionName: 'users', + collectionDisplayName: 'Users', + primaryKeyFields: ['id'], + fields: collectionRefs.users.fields, + actions: collectionRefs.users.actions, + 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', [1, 2], { status: 'done' }); + + expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); + }); + }); + + describe('getRelatedData', () => { + it('should return RecordData[] with recordId extracted from PK fields', async () => { + mockRelation.list.mockResolvedValue([ + { id: 10, title: 'Post A' }, + { id: 11, title: 'Post B' }, + ]); + + const result = await port.getRelatedData('users', [42], 'posts'); + + expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); + expect(result).toEqual([ + { + recordId: [10], + collectionName: 'posts', + collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], + fields: collectionRefs.posts.fields, + actions: collectionRefs.posts.actions, + values: { id: 10, title: 'Post A' }, + }, + { + recordId: [11], + collectionName: 'posts', + collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], + fields: collectionRefs.posts.fields, + actions: collectionRefs.posts.actions, + 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].recordId).toEqual([1]); + }); + + it('should return an empty array when no related data exists', async () => { + mockRelation.list.mockResolvedValue([]); + + expect(await port.getRelatedData('users', [42], 'posts')).toEqual([]); + }); + }); + + describe('getActions', () => { + it('should return ActionRef[] from CollectionRef', async () => { + expect(await port.getActions('users')).toEqual([ + { name: 'sendEmail', displayName: 'Send Email' }, + { name: 'archive', displayName: 'Archive' }, + ]); + }); + + it('should return an empty array for an unknown collection', async () => { + expect(await port.getActions('unknown')).toEqual([]); + }); + }); + + describe('executeAction', () => { + it('should encode recordIds to pipe format and call execute', async () => { + mockAction.execute.mockResolvedValue({ success: 'done' }); + + const result = await port.executeAction('users', 'sendEmail', [[1], [2]]); + + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); + 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', + ); + }); + }); +}); 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'); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts new file mode 100644 index 0000000000..73f5e716b1 --- /dev/null +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -0,0 +1,400 @@ +import type { RunStore } from '../../src/ports/run-store'; +import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; +import type { StepDefinition } from '../../src/types/step-definition'; +import type { StepExecutionData } from '../../src/types/step-execution-data'; +import type { StepHistory } from '../../src/types/step-history'; +import type { BaseMessage, SystemMessage } from '@langchain/core/messages'; +import type { DynamicStructuredTool } from '@langchain/core/tools'; + +import { MalformedToolCallError, MissingToolCallError } from '../../src/errors'; +import BaseStepExecutor from '../../src/executors/base-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +/** Concrete subclass that exposes protected methods for testing. */ +class TestableExecutor extends BaseStepExecutor { + async execute(): Promise { + throw new Error('not used'); + } + + override buildPreviousStepsMessages(): Promise { + return super.buildPreviousStepsMessages(); + } + + override invokeWithTool>( + messages: BaseMessage[], + tool: DynamicStructuredTool, + ): Promise { + return super.invokeWithTool(messages, tool); + } +} + +function makeHistoryEntry( + overrides: { stepId?: string; stepIndex?: number; prompt?: string } = {}, +): { step: StepDefinition; stepHistory: StepHistory } { + return { + step: { + id: overrides.stepId ?? 'step-1', + type: StepType.Condition, + options: ['A', 'B'], + prompt: overrides.prompt ?? 'Pick one', + }, + stepHistory: { + type: 'condition', + stepId: overrides.stepId ?? 'step-1', + stepIndex: overrides.stepIndex ?? 0, + status: 'success', + }, + }; +} + +function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { + return { + getRecords: jest.fn().mockResolvedValue([]), + getRecord: jest.fn().mockResolvedValue(null), + saveRecord: jest.fn().mockResolvedValue(undefined), + getStepExecutions: jest.fn().mockResolvedValue(stepExecutions), + getStepExecution: jest.fn().mockResolvedValue(null), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + }; +} + +function makeContext(overrides: Partial = {}): ExecutionContext { + return { + runId: 'run-1', + model: {} as ExecutionContext['model'], + agentPort: {} as ExecutionContext['agentPort'], + workflowPort: {} as ExecutionContext['workflowPort'], + runStore: makeMockRunStore(), + history: [], + remoteTools: [], + ...overrides, + }; +} + +describe('BaseStepExecutor', () => { + describe('buildPreviousStepsMessages', () => { + it('returns empty array for empty history', async () => { + const executor = new TestableExecutor(makeContext()); + + expect(await executor.buildPreviousStepsMessages()).toEqual([]); + }); + + it('includes prompt and executionParams from previous steps', async () => { + const executor = new TestableExecutor( + makeContext({ + history: [makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Approve?' })], + runStore: makeMockRunStore([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Order is valid' }, + executionResult: { answer: 'Yes' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "cond-1"'); + expect(result).toContain('Prompt: Approve?'); + expect(result).toContain('Result: {"answer":"Yes","reasoning":"Order is valid"}'); + }); + + it('falls back to History when step has no executionParams in RunStore', async () => { + const executor = new TestableExecutor( + makeContext({ + history: [ + makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0 }), + makeHistoryEntry({ stepId: 'cond-2', stepIndex: 1, prompt: 'Second?' }), + ], + runStore: makeMockRunStore([ + { type: 'condition', stepIndex: 0 }, + { + type: 'condition', + stepIndex: 1, + executionParams: { answer: 'No', reasoning: 'Clearly no' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "cond-1"'); + expect(result).toContain('History: {"status":"success"}'); + expect(result).toContain('Step "cond-2"'); + expect(result).toContain('Result: {"answer":"No","reasoning":"Clearly no"}'); + }); + + it('falls back to History when no matching step execution in RunStore', async () => { + const executor = new TestableExecutor( + makeContext({ + history: [ + makeHistoryEntry({ stepId: 'orphan', stepIndex: 5, prompt: 'Orphan step' }), + makeHistoryEntry({ stepId: 'matched', stepIndex: 1, prompt: 'Matched step' }), + ], + runStore: makeMockRunStore([ + { + type: 'condition', + stepIndex: 1, + executionParams: { answer: 'B', reasoning: 'Option B fits' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "orphan"'); + expect(result).toContain('History: {"status":"success"}'); + expect(result).toContain('Step "matched"'); + expect(result).toContain('Result: {"answer":"B","reasoning":"Option B fits"}'); + }); + + it('includes selectedOption in History for condition steps', async () => { + const entry = makeHistoryEntry({ + stepId: 'cond-approval', + stepIndex: 0, + prompt: 'Approved?', + }); + (entry.stepHistory as { selectedOption?: string }).selectedOption = 'Yes'; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "cond-approval"'); + expect(result).toContain('"selectedOption":"Yes"'); + }); + + it('includes error in History for failed steps', async () => { + const entry = makeHistoryEntry({ + stepId: 'failing-step', + stepIndex: 0, + prompt: 'Do something', + }); + entry.stepHistory.status = 'error'; + (entry.stepHistory as { error?: string }).error = 'AI could not match an option'; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('"status":"error"'); + expect(result).toContain('"error":"AI could not match an option"'); + }); + + it('includes status in History for ai-task steps without RunStore data', async () => { + const entry: { step: StepDefinition; stepHistory: StepHistory } = { + step: { id: 'ai-step', type: StepType.ReadRecord, prompt: 'Run task' }, + stepHistory: { type: 'ai-task', stepId: 'ai-step', stepIndex: 0, status: 'awaiting-input' }, + }; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "ai-step"'); + expect(result).toContain('History: {"status":"awaiting-input"}'); + }); + + it('uses Result when RunStore has executionParams, History otherwise', async () => { + const condEntry = makeHistoryEntry({ + stepId: 'cond-1', + stepIndex: 0, + prompt: 'Approved?', + }); + (condEntry.stepHistory as { selectedOption?: string }).selectedOption = 'Yes'; + + const aiEntry: { step: StepDefinition; stepHistory: StepHistory } = { + step: { id: 'read-customer', type: StepType.ReadRecord, prompt: 'Read name' }, + stepHistory: { type: 'ai-task', stepId: 'read-customer', stepIndex: 1, status: 'success' }, + }; + + const executor = new TestableExecutor( + makeContext({ + history: [condEntry, aiEntry], + runStore: makeMockRunStore([ + { + type: 'ai-task', + stepIndex: 1, + executionParams: { answer: 'John Doe' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Step "cond-1"'); + expect(result).toContain('History: {"status":"success","selectedOption":"Yes"}'); + expect(result).toContain('Step "read-customer"'); + expect(result).toContain('Result: {"answer":"John Doe"}'); + }); + + it('prefers RunStore executionParams over History fallback', async () => { + const entry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Pick one' }); + (entry.stepHistory as { selectedOption?: string }).selectedOption = 'A'; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'A', reasoning: 'Best fit' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Result: {"answer":"A","reasoning":"Best fit"}'); + expect(result).not.toContain('History:'); + }); + + it('shows "(no prompt)" when step has no prompt', async () => { + const entry = makeHistoryEntry({ stepIndex: 0 }); + entry.step.prompt = undefined; + + const executor = new TestableExecutor( + makeContext({ + history: [entry], + runStore: makeMockRunStore([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'A', reasoning: 'Only option' }, + }, + ]), + }), + ); + + const result = await executor + .buildPreviousStepsMessages() + .then(msgs => msgs[0]?.content ?? ''); + + expect(result).toContain('Prompt: (no prompt)'); + }); + }); + + describe('invokeWithTool', () => { + function makeMockModel(response: unknown) { + const invoke = jest.fn().mockResolvedValue(response); + + return { + model: { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model'], + invoke, + }; + } + + const dummyTool = {} as DynamicStructuredTool; + const dummyMessages = [] as BaseMessage[]; + + it('returns args from the first tool call', async () => { + const { model } = makeMockModel({ + tool_calls: [{ name: 'tool', args: { key: 'value' }, id: 'c1' }], + }); + const executor = new TestableExecutor(makeContext({ model })); + + const result = await executor.invokeWithTool(dummyMessages, dummyTool); + + expect(result).toEqual({ key: 'value' }); + }); + + it('binds tool with tool_choice "any"', async () => { + const { model } = makeMockModel({ + tool_calls: [{ name: 'tool', args: {}, id: 'c1' }], + }); + const executor = new TestableExecutor(makeContext({ model })); + + await executor.invokeWithTool(dummyMessages, dummyTool); + + expect(model.bindTools).toHaveBeenCalledWith([dummyTool], { tool_choice: 'any' }); + }); + + it('throws MissingToolCallError when tool_calls is undefined', async () => { + const { model } = makeMockModel({}); + const executor = new TestableExecutor(makeContext({ model })); + + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + MissingToolCallError, + ); + }); + + it('throws MissingToolCallError when tool_calls is empty', async () => { + const { model } = makeMockModel({ tool_calls: [] }); + const executor = new TestableExecutor(makeContext({ model })); + + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + MissingToolCallError, + ); + }); + + it('throws MalformedToolCallError when invalid_tool_calls is present', async () => { + const { model } = makeMockModel({ + tool_calls: [], + invalid_tool_calls: [{ name: 'my-tool', args: '{bad', error: 'Parse error' }], + }); + const executor = new TestableExecutor(makeContext({ model })); + + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + MalformedToolCallError, + ); + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + 'AI returned a malformed tool call for "my-tool": Parse error', + ); + }); + + it('throws MalformedToolCallError with "unknown" when invalid_tool_call has no name', async () => { + const { model } = makeMockModel({ + tool_calls: [], + invalid_tool_calls: [{ error: 'Something broke' }], + }); + const executor = new TestableExecutor(makeContext({ model })); + + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + MalformedToolCallError, + ); + await expect(executor.invokeWithTool(dummyMessages, dummyTool)).rejects.toThrow( + 'AI returned a malformed tool call for "unknown": Something broke', + ); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts new file mode 100644 index 0000000000..ba7fe7f34d --- /dev/null +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -0,0 +1,310 @@ +import type { RunStore } from '../../src/ports/run-store'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { ConditionStepDefinition } from '../../src/types/step-definition'; +import type { ConditionStepHistory } from '../../src/types/step-history'; + +import ConditionStepExecutor from '../../src/executors/condition-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): ConditionStepDefinition { + return { + id: 'cond-1', + type: StepType.Condition, + options: ['Approve', 'Reject'], + prompt: 'Should we approve this?', + ...overrides, + }; +} + +function makeStepHistory(overrides: Partial = {}): ConditionStepHistory { + return { + type: 'condition', + stepId: 'cond-1', + stepIndex: 0, + status: 'success', + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getRecords: jest.fn().mockResolvedValue([]), + getRecord: jest.fn().mockResolvedValue(null), + saveRecord: jest.fn().mockResolvedValue(undefined), + getStepExecutions: jest.fn().mockResolvedValue([]), + getStepExecution: jest.fn().mockResolvedValue(null), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockModel(toolCallArgs?: Record) { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs + ? [{ name: 'choose-gateway-option', args: toolCallArgs, id: 'call_1' }] + : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext(overrides: Partial = {}): ExecutionContext { + return { + runId: 'run-1', + model: makeMockModel().model, + agentPort: {} as ExecutionContext['agentPort'], + workflowPort: {} as ExecutionContext['workflowPort'], + runStore: makeMockRunStore(), + history: [], + remoteTools: [], + ...overrides, + }; +} + +describe('ConditionStepExecutor', () => { + describe('immutability', () => { + it('does not mutate the input stepHistory', async () => { + const mockModel = makeMockModel({ + option: 'Reject', + reasoning: 'Incomplete', + question: 'Approve?', + }); + const stepHistory = makeStepHistory(); + const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model })); + + const result = await executor.execute(makeStep(), stepHistory); + + expect(result.stepHistory).not.toBe(stepHistory); + expect(stepHistory.status).toBe('success'); + expect(stepHistory.selectedOption).toBeUndefined(); + }); + }); + + describe('AI decision', () => { + it('calls AI and returns selected option on success', async () => { + const mockModel = makeMockModel({ + option: 'Reject', + reasoning: 'The request is incomplete', + question: 'Should we approve?', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new ConditionStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('success'); + expect((result.stepHistory as ConditionStepHistory).selectedOption).toBe('Reject'); + + expect(mockModel.bindTools).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'choose-gateway-option' })], + { tool_choice: 'any' }, + ); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith({ + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Reject', reasoning: 'The request is incomplete' }, + executionResult: { answer: 'Reject' }, + }); + }); + + it('binds a tool with all step options and nullable for no-match', async () => { + const mockModel = makeMockModel({ + option: 'Approve', + reasoning: 'Looks good', + question: 'Should we?', + }); + const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model })); + + await executor.execute( + makeStep({ options: ['Approve', 'Reject', 'Defer'] }), + makeStepHistory(), + ); + + const tool = mockModel.bindTools.mock.calls[0][0][0]; + expect(tool.name).toBe('choose-gateway-option'); + expect(tool.schema.parse({ option: 'Approve', reasoning: 'r', question: 'q' })).toBeTruthy(); + expect(tool.schema.parse({ option: 'Defer', reasoning: 'r', question: 'q' })).toBeTruthy(); + expect(tool.schema.parse({ option: null, reasoning: 'r', question: 'q' })).toBeTruthy(); + expect(() => + tool.schema.parse({ option: 'InvalidOption', reasoning: 'r', question: 'q' }), + ).toThrow(); + }); + + it('sends system prompt + user question as separate messages', async () => { + const mockModel = makeMockModel({ + option: 'Approve', + reasoning: 'Looks good', + question: 'Should we approve?', + }); + const context = makeContext({ model: mockModel.model }); + const executor = new ConditionStepExecutor(context); + + await executor.execute( + makeStep({ prompt: 'Custom prompt for this step' }), + makeStepHistory(), + ); + + const messages = mockModel.invoke.mock.calls[0][0]; + expect(messages).toHaveLength(2); + expect(messages[0].content).toContain('workflow gateway decision'); + expect(messages[0].content).toContain('80% confident'); + expect(messages[1].content).toBe('**Question**: Custom prompt for this step'); + }); + + it('uses default question when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ + option: 'Approve', + reasoning: 'Default', + question: 'Approve?', + }); + const context = makeContext({ model: mockModel.model }); + const executor = new ConditionStepExecutor(context); + + await executor.execute(makeStep({ prompt: undefined }), makeStepHistory()); + + const messages = mockModel.invoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Question**: Choose the most appropriate option.'); + }); + + it('prepends previous steps summary as separate SystemMessage', async () => { + const mockModel = makeMockModel({ + option: 'Approve', + reasoning: 'Based on previous decision', + question: 'Final approval?', + }); + const runStore = makeMockRunStore({ + getStepExecution: jest.fn().mockResolvedValue(null), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Validated by manager' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + history: [ + { + step: { + id: 'prev-step', + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Previous question', + }, + stepHistory: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new ConditionStepExecutor(context); + + await executor.execute( + makeStep({ id: 'cond-2' }), + makeStepHistory({ stepId: 'cond-2', stepIndex: 1 }), + ); + + const messages = mockModel.invoke.mock.calls[0][0]; + expect(messages).toHaveLength(3); + expect(messages[0].content).toContain('Previous question'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('workflow gateway decision'); + expect(messages[2].content).toContain('**Question**'); + }); + }); + + describe('no-match fallback', () => { + it('returns manual-decision when AI selects null', async () => { + const mockModel = makeMockModel({ + option: null, + reasoning: 'None apply', + question: 'N/A', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new ConditionStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('manual-decision'); + expect(result.stepHistory.error).toBeUndefined(); + expect((result.stepHistory as ConditionStepHistory).selectedOption).toBeUndefined(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith({ + type: 'condition', + stepIndex: 0, + executionParams: { answer: null, reasoning: 'None apply' }, + executionResult: undefined, + }); + }); + + it('returns error when AI returns an invalid (malformed) tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'choose-gateway-option', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new ConditionStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe( + 'AI returned a malformed tool call for "choose-gateway-option": JSON parse error', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('returns error status when model invocation fails', async () => { + const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + }); + const executor = new ConditionStepExecutor(context); + + const result = await executor.execute(makeStep(), makeStepHistory()); + + expect(result.stepHistory.status).toBe('error'); + expect(result.stepHistory.error).toBe('API timeout'); + }); + + it('lets run store errors propagate', async () => { + const mockModel = makeMockModel({ + option: 'Approve', + reasoning: 'OK', + question: 'Approve?', + }); + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), + }); + const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model, runStore })); + + await expect(executor.execute(makeStep(), makeStepHistory())).rejects.toThrow('Storage full'); + }); + }); +}); diff --git a/packages/workflow-executor/test/index.test.ts b/packages/workflow-executor/test/index.test.ts new file mode 100644 index 0000000000..05affa035c --- /dev/null +++ b/packages/workflow-executor/test/index.test.ts @@ -0,0 +1,18 @@ +import { StepType } from '../src/index'; + +describe('StepType', () => { + it('should expose exactly 5 step types', () => { + const values = Object.values(StepType); + expect(values).toHaveLength(5); + }); + + it.each([ + ['Condition', 'condition'], + ['ReadRecord', 'read-record'], + ['UpdateRecord', 'update-record'], + ['TriggerAction', 'trigger-action'], + ['LoadRelatedRecord', 'load-related-record'], + ] as const)('should have %s = "%s"', (key, value) => { + expect(StepType[key]).toBe(value); + }); +}); diff --git a/packages/workflow-executor/tsconfig.eslint.json b/packages/workflow-executor/tsconfig.eslint.json new file mode 100644 index 0000000000..9bdc52705d --- /dev/null +++ b/packages/workflow-executor/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.eslint.json" +} diff --git a/packages/workflow-executor/tsconfig.json b/packages/workflow-executor/tsconfig.json new file mode 100644 index 0000000000..e0d66374ae --- /dev/null +++ b/packages/workflow-executor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 023ddb64f2..c7c7add7ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2314,6 +2314,23 @@ uuid "^10.0.0" zod "^3.25.76 || ^4" +"@langchain/core@1.1.33": + version "1.1.33" + resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.33.tgz#414536e9d0a6f90576502e532336104360ed4392" + integrity sha512-At1ooBmPlHMkhTkG6NqeOVjNscuJwneBB8F88rFRvBvIfhTACVLzEwMiZFWNTM8DzUXUOcxxqS7xKRyr6JBbOQ== + dependencies: + "@cfworker/json-schema" "^4.0.2" + "@standard-schema/spec" "^1.1.0" + ansi-styles "^5.0.0" + camelcase "6" + decamelize "1.2.0" + js-tiktoken "^1.0.12" + langsmith ">=0.5.0 <1.0.0" + mustache "^4.2.0" + p-queue "^6.6.2" + uuid "^11.1.0" + zod "^3.25.76 || ^4" + "@langchain/langgraph-checkpoint@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" @@ -4169,6 +4186,11 @@ dependencies: tslib "^2.6.2" +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -11297,6 +11319,18 @@ koa@^3.0.1: semver "^7.6.3" uuid "^10.0.0" +"langsmith@>=0.5.0 <1.0.0": + version "0.5.10" + resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.10.tgz#f0df23538e6a7c2928787030cedfb4be9d5b3db6" + integrity sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A== + dependencies: + "@types/uuid" "^10.0.0" + chalk "^5.6.2" + console-table-printer "^2.12.1" + p-queue "^6.6.2" + semver "^7.6.3" + uuid "^10.0.0" + lerna@^8.2.3: version "8.2.3" resolved "https://registry.yarnpkg.com/lerna/-/lerna-8.2.3.tgz#0a9c07eda4cfac84a480b3e66915189ccfb5bd2c" @@ -17288,6 +17322,11 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + uuid@^13.0.0: version "13.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" @@ -17825,6 +17864,11 @@ zod-to-json-schema@^3.25.1: resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz#7f24962101a439ddade2bf1aeab3c3bfec7d84ba" integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== +zod@4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + "zod@^3.25 || ^4.0", "zod@^3.25.76 || ^4", zod@^4.3.5: version "4.3.5" resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.5.tgz#aeb269a6f9fc259b1212c348c7c5432aaa474d2a"