Add workflow runner (#6458)

- add workflow runner module
- add an endpoint to trigger a workflow via api
- improve error handling for serverless functions

## Testing
- create 2 serverless functions
- create a workflow
- create this workflow Version
```
{
  "type": "MANUAL",
  "input": {"b": "bb"},
  "nextAction": {
    "name": "step_1",
    "displayName": "Code",
    "type": "CODE",
    "valid": true,
    "settings": {
      "serverlessFunctionId": "Serverless function 1 Id",
      "errorHandlingOptions": {
        "retryOnFailure": {
          "value": false
        },
        "continueOnFailure": {
          "value": false
        }
      }
    },
    "nextAction": {
      "name": "step_1",
      "displayName": "Code",
      "type": "CODE",
      "valid": true,
      "settings": {
        "serverlessFunctionId": "Serverless function 1 Id",
        "errorHandlingOptions": {
          "retryOnFailure": {
            "value": false
          },
          "continueOnFailure": {
            "value": false
          }
        }
      },
      "nextAction": {
        "name": "step_1",
        "displayName": "Code",
        "type": "CODE",
        "valid": true,
        "settings": {
          "serverlessFunctionId": "Serverless function 2 Id",
          "errorHandlingOptions": {
            "retryOnFailure": {
              "value": false
            },
            "continueOnFailure": {
              "value": false
            }
          }
        }
      }
    }
  }
}

`
``
- call 
```
mutation Trigger {
  triggerWorkflow(workflowVersionId: "WORKFLOW_VERSION_ID") {
    result
  }
}
```
- try when errors are injected in serverless function
This commit is contained in:
martmull
2024-07-31 12:48:33 +02:00
committed by GitHub
parent b8496d22b6
commit 6b4c79ff0d
42 changed files with 639 additions and 150 deletions

View File

@ -36,7 +36,7 @@ const documents = {
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n sourceCodeFullPath\n runtime\n syncStatus\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n sourceCodeFullPath\n runtime\n syncStatus\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument, "\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
"\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, "\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n result\n }\n }\n": types.ExecuteOneServerlessFunctionDocument, "\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
"\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument, "\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument,
"\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n": types.GetManyServerlessFunctionsDocument, "\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n": types.GetManyServerlessFunctionsDocument,
"\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument, "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument,
@ -151,7 +151,7 @@ export function graphql(source: "\n \n mutation DeleteOneServerlessFunction($i
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n result\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n result\n }\n }\n"]; export function graphql(source: "\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n data\n duration\n status\n error\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n data\n duration\n status\n error\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,39 @@
import { useRecoilValue } from 'recoil';
import {
DEFAULT_OUTPUT_VALUE,
settingsServerlessFunctionOutputState,
} from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import styled from '@emotion/styled';
import { IconSquareRoundedCheck } from 'twenty-ui';
import { useTheme } from '@emotion/react';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
const StyledOutput = styled.div<{ status?: ServerlessFunctionExecutionStatus }>`
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
color: ${({ theme, status }) =>
status === ServerlessFunctionExecutionStatus.Success
? theme.color.turquoise
: theme.color.red};
display: flex;
`;
export const SettingsServerlessFunctionsOutputMetadataInfo = () => {
const theme = useTheme();
const settingsServerlessFunctionOutput = useRecoilValue(
settingsServerlessFunctionOutputState,
);
return settingsServerlessFunctionOutput.data === DEFAULT_OUTPUT_VALUE ? (
'Output'
) : (
<StyledOutput status={settingsServerlessFunctionOutput.status}>
<IconSquareRoundedCheck size={theme.icon.size.md} />
{settingsServerlessFunctionOutput.status ===
ServerlessFunctionExecutionStatus.Success
? '200 OK'
: '500 Error'}
{' - '}
{settingsServerlessFunctionOutput.duration}ms
</StyledOutput>
);
};

View File

@ -10,6 +10,7 @@ import { useRecoilState, useRecoilValue } from 'recoil';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState'; import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState'; import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState'; import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
import { SettingsServerlessFunctionsOutputMetadataInfo } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsOutputMetadataInfo';
const StyledInputsContainer = styled.div` const StyledInputsContainer = styled.div`
display: flex; display: flex;
@ -31,27 +32,32 @@ export const SettingsServerlessFunctionTestTab = ({
const [settingsServerlessFunctionInput, setSettingsServerlessFunctionInput] = const [settingsServerlessFunctionInput, setSettingsServerlessFunctionInput] =
useRecoilState(settingsServerlessFunctionInputState); useRecoilState(settingsServerlessFunctionInputState);
const InputHeaderButton = ( const result =
<Button settingsServerlessFunctionOutput.data ||
title="Run Function" settingsServerlessFunctionOutput.error ||
variant="primary" '';
accent="blue"
size="small" const InputHeader = (
Icon={IconPlayerPlay} <CoreEditorHeader
onClick={handleExecute} title={'Input'}
rightNodes={[
<Button
title="Run Function"
variant="primary"
accent="blue"
size="small"
Icon={IconPlayerPlay}
onClick={handleExecute}
/>,
]}
/> />
); );
const InputHeader = (
<CoreEditorHeader title={'Input'} rightNodes={[InputHeaderButton]} />
);
const OutputHeaderButton = (
<LightCopyIconButton copyText={settingsServerlessFunctionOutput} />
);
const OutputHeader = ( const OutputHeader = (
<CoreEditorHeader title={'Output'} rightNodes={[OutputHeaderButton]} /> <CoreEditorHeader
leftNodes={[<SettingsServerlessFunctionsOutputMetadataInfo />]}
rightNodes={[<LightCopyIconButton copyText={result} />]}
/>
); );
return ( return (
@ -69,7 +75,7 @@ export const SettingsServerlessFunctionTestTab = ({
header={InputHeader} header={InputHeader}
/> />
<CodeEditor <CodeEditor
value={settingsServerlessFunctionOutput} value={result}
height={settingsServerlessFunctionCodeEditorOutputParams.height} height={settingsServerlessFunctionCodeEditorOutputParams.height}
language={settingsServerlessFunctionCodeEditorOutputParams.language} language={settingsServerlessFunctionCodeEditorOutputParams.language}
options={{ readOnly: true, domReadOnly: true }} options={{ readOnly: true, domReadOnly: true }}

View File

@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState'; import {
DEFAULT_OUTPUT_VALUE,
settingsServerlessFunctionOutputState,
} from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState'; import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState';
export const SettingsServerlessFunctionTestTabEffect = () => { export const SettingsServerlessFunctionTestTabEffect = () => {
@ -10,14 +13,11 @@ export const SettingsServerlessFunctionTestTabEffect = () => {
const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState( const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState(
settingsServerlessFunctionCodeEditorOutputParamsState, settingsServerlessFunctionCodeEditorOutputParamsState,
); );
try { if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) {
JSON.parse(settingsServerlessFunctionOutput);
setSettingsServerlessFunctionCodeEditorOutputParams({ setSettingsServerlessFunctionCodeEditorOutputParams({
language: 'json', language: 'json',
height: 300, height: 300,
}); });
} catch {
return <></>;
} }
return <></>; return <></>;
}; };

View File

@ -3,7 +3,10 @@ import { gql } from '@apollo/client';
export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql` export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql`
mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) { mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {
executeOneServerlessFunction(id: $id, payload: $payload) { executeOneServerlessFunction(id: $id, payload: $payload) {
result data
duration
status
error
} }
} }
`; `;

View File

@ -1,6 +1,18 @@
import { createState } from 'twenty-ui'; import { createState } from 'twenty-ui';
import { ServerlessFunctionExecutionStatus } from '~/generated-metadata/graphql';
export const settingsServerlessFunctionOutputState = createState<string>({ type settingsServerlessFunctionOutput = {
key: 'settingsServerlessFunctionOutputState', data?: string;
defaultValue: 'Enter an input above then press "run Function"', duration?: number;
}); status?: ServerlessFunctionExecutionStatus;
error?: string;
};
export const DEFAULT_OUTPUT_VALUE =
'Enter an input above then press "run Function"';
export const settingsServerlessFunctionOutputState =
createState<settingsServerlessFunctionOutput>({
key: 'settingsServerlessFunctionOutputState',
defaultValue: { data: DEFAULT_OUTPUT_VALUE },
});

View File

@ -78,13 +78,24 @@ export const SettingsServerlessFunctionDetail = () => {
serverlessFunctionId, serverlessFunctionId,
JSON.parse(settingsServerlessFunctionInput), JSON.parse(settingsServerlessFunctionInput),
); );
setSettingsServerlessFunctionOutput( setSettingsServerlessFunctionOutput({
JSON.stringify( data: result?.data?.executeOneServerlessFunction?.data
result?.data?.executeOneServerlessFunction?.result, ? JSON.stringify(
null, result?.data?.executeOneServerlessFunction?.data,
4, null,
), 4,
); )
: undefined,
duration: result?.data?.executeOneServerlessFunction?.duration,
status: result?.data?.executeOneServerlessFunction?.status,
error: result?.data?.executeOneServerlessFunction?.error
? JSON.stringify(
result?.data?.executeOneServerlessFunction?.error,
null,
4,
)
: undefined,
});
} catch (err) { } catch (err) {
enqueueSnackBar( enqueueSnackBar(
(err as Error)?.message || 'An error occurred while executing function', (err as Error)?.message || 'An error occurred while executing function',
@ -92,7 +103,6 @@ export const SettingsServerlessFunctionDetail = () => {
variant: SnackBarVariant.Error, variant: SnackBarVariant.Error,
}, },
); );
setSettingsServerlessFunctionOutput(JSON.stringify(err, null, 4));
} }
setActiveTabId('test'); setActiveTabId('test');
}; };

View File

@ -0,0 +1,14 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsObject } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
@ObjectType('WorkflowTriggerResult')
export class WorkflowTriggerResultDTO {
@IsObject()
@Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format',
nullable: true,
})
result?: JSON;
}

View File

@ -14,6 +14,7 @@ export const workflowTriggerGraphqlApiExceptionHandler = (error: Error) => {
throw new UserInputError(error.message); throw new UserInputError(error.message);
case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER: case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_TRIGGER:
case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION: case WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION:
case WorkflowTriggerExceptionCode.INVALID_ACTION_TYPE:
default: default:
throw new InternalServerError(error.message); throw new InternalServerError(error.message);
} }

View File

@ -1,9 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/workflow-trigger.resolver'; import { WorkflowTriggerResolver } from 'src/engine/core-modules/workflow/workflow-trigger.resolver';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowTriggerService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.service'; import { WorkflowTriggerService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.service';
import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workflow-runner.module';
@Module({ @Module({
imports: [WorkflowCommonModule, WorkflowRunnerModule],
providers: [WorkflowTriggerService, WorkflowTriggerResolver], providers: [WorkflowTriggerService, WorkflowTriggerResolver],
}) })
export class WorkflowTriggerModule {} export class WorkflowTriggerModule {}

View File

@ -6,6 +6,7 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { WorkflowTriggerService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.service'; import { WorkflowTriggerService } from 'src/modules/workflow/workflow-trigger/workflow-trigger.service';
import { WorkflowTriggerResultDTO } from 'src/engine/core-modules/workflow/dtos/workflow-trigger-result.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Resolver() @Resolver()
@ -28,4 +29,21 @@ export class WorkflowTriggerResolver {
workflowTriggerGraphqlApiExceptionHandler(error); workflowTriggerGraphqlApiExceptionHandler(error);
} }
} }
@Mutation(() => WorkflowTriggerResultDTO)
async triggerWorkflow(
@AuthWorkspace() { id: workspaceId }: Workspace,
@Args('workflowVersionId') workflowVersionId: string,
) {
try {
return {
result: await this.workflowTriggerService.runWorkflow(
workspaceId,
workflowVersionId,
),
};
} catch (error) {
workflowTriggerGraphqlApiExceptionHandler(error);
}
}
} }

View File

@ -16,4 +16,5 @@ export enum MessageQueue {
recordPositionBackfillQueue = 'record-position-backfill-queue', recordPositionBackfillQueue = 'record-position-backfill-queue',
entityEventsToDbQueue = 'entity-events-to-db-queue', entityEventsToDbQueue = 'entity-events-to-db-queue',
testQueue = 'test-queue', testQueue = 'test-queue',
workflowQueue = 'workflow-queue',
} }

View File

@ -1,4 +1,18 @@
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
export type ServerlessExecuteError = {
errorType: string;
errorMessage: string;
stackTrace: string;
};
export type ServerlessExecuteResult = {
data: object | null;
duration: number;
status: ServerlessFunctionExecutionStatus;
error?: ServerlessExecuteError;
};
export interface ServerlessDriver { export interface ServerlessDriver {
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>; delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
@ -6,5 +20,5 @@ export interface ServerlessDriver {
execute( execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined, payload: object | undefined,
): Promise<object>; ): Promise<ServerlessExecuteResult>;
} }

View File

@ -13,13 +13,17 @@ import {
import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand'; import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand';
import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand'; import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand';
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface'; import {
ServerlessDriver,
ServerlessExecuteResult,
} from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { createZipFile } from 'src/engine/integrations/serverless/drivers/utils/create-zip-file'; import { createZipFile } from 'src/engine/integrations/serverless/drivers/utils/create-zip-file';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver'; import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver';
import { BuildDirectoryManagerService } from 'src/engine/integrations/serverless/drivers/services/build-directory-manager.service'; import { BuildDirectoryManagerService } from 'src/engine/integrations/serverless/drivers/services/build-directory-manager.service';
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
export interface LambdaDriverOptions extends LambdaClientConfig { export interface LambdaDriverOptions extends LambdaClientConfig {
fileStorageService: FileStorageService; fileStorageService: FileStorageService;
@ -134,7 +138,8 @@ export class LambdaDriver
async execute( async execute(
functionToExecute: ServerlessFunctionEntity, functionToExecute: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
): Promise<object> { ): Promise<ServerlessExecuteResult> {
const startTime = Date.now();
const params = { const params = {
FunctionName: functionToExecute.id, FunctionName: functionToExecute.id,
Payload: JSON.stringify(payload), Payload: JSON.stringify(payload),
@ -144,10 +149,25 @@ export class LambdaDriver
const result = await this.lambdaClient.send(command); const result = await this.lambdaClient.send(command);
if (!result.Payload) { const parsedResult = result.Payload
return {}; ? JSON.parse(result.Payload.transformToString())
: {};
const duration = Date.now() - startTime;
if (result.FunctionError) {
return {
data: null,
duration,
status: ServerlessFunctionExecutionStatus.ERROR,
error: parsedResult,
};
} }
return JSON.parse(result.Payload.transformToString()); return {
data: parsedResult,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
} }
} }

View File

@ -5,13 +5,18 @@ import { fork } from 'child_process';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface'; import {
ServerlessDriver,
ServerlessExecuteError,
ServerlessExecuteResult,
} from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content'; import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { BUILD_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/build-file-name'; import { BUILD_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/build-file-name';
import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver'; import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver';
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
export interface LocalDriverOptions { export interface LocalDriverOptions {
fileStorageService: FileStorageService; fileStorageService: FileStorageService;
@ -51,7 +56,8 @@ export class LocalDriver
async execute( async execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
): Promise<object> { ): Promise<ServerlessExecuteResult> {
const startTime = Date.now();
const fileStream = await this.fileStorageService.read({ const fileStream = await this.fileStorageService.read({
folderPath: this.getFolderPath(serverlessFunction), folderPath: this.getFolderPath(serverlessFunction),
filename: BUILD_FILE_NAME, filename: BUILD_FILE_NAME,
@ -83,8 +89,23 @@ export class LocalDriver
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = fork(tmpFilePath, { silent: true }); const child = fork(tmpFilePath, { silent: true });
child.on('message', (message: object) => { child.on('message', (message: object | ServerlessExecuteError) => {
resolve(message); const duration = Date.now() - startTime;
if ('errorType' in message) {
resolve({
data: null,
duration,
error: message,
status: ServerlessFunctionExecutionStatus.ERROR,
});
} else {
resolve({
data: message,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
});
}
child.kill(); child.kill();
fs.unlink(tmpFilePath); fs.unlink(tmpFilePath);
}); });
@ -93,8 +114,8 @@ export class LocalDriver
const stackTrace = data const stackTrace = data
.toString() .toString()
.split('\n') .split('\n')
.filter((line) => line.trim() !== ''); .filter((line: string) => line.trim() !== '');
const errorTrace = stackTrace.filter((line) => const errorTrace = stackTrace.filter((line: string) =>
line.includes('Error: '), line.includes('Error: '),
)?.[0]; )?.[0];
@ -105,11 +126,17 @@ export class LocalDriver
errorType = errorTrace.split(':')[0]; errorType = errorTrace.split(':')[0];
errorMessage = errorTrace.split(': ')[1]; errorMessage = errorTrace.split(': ')[1];
} }
const duration = Date.now() - startTime;
resolve({ resolve({
errorType, data: null,
errorMessage, duration,
stackTrace: stackTrace, status: ServerlessFunctionExecutionStatus.ERROR,
error: {
errorType,
errorMessage,
stackTrace: stackTrace,
},
}); });
child.kill(); child.kill();
fs.unlink(tmpFilePath); fs.unlink(tmpFilePath);

View File

@ -1,6 +1,9 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface'; import {
ServerlessDriver,
ServerlessExecuteResult,
} from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { SERVERLESS_DRIVER } from 'src/engine/integrations/serverless/serverless.constants'; import { SERVERLESS_DRIVER } from 'src/engine/integrations/serverless/serverless.constants';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
@ -20,7 +23,7 @@ export class ServerlessService implements ServerlessDriver {
async execute( async execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
) { ): Promise<ServerlessExecuteResult> {
return this.driver.execute(serverlessFunction, payload); return this.driver.execute(serverlessFunction, payload);
} }
} }

View File

@ -1,13 +1,44 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsObject } from 'class-validator'; import { IsObject, IsOptional } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json'; import graphqlTypeJson from 'graphql-type-json';
export enum ServerlessFunctionExecutionStatus {
SUCCESS = 'SUCCESS',
ERROR = 'ERROR',
}
registerEnumType(ServerlessFunctionExecutionStatus, {
name: 'ServerlessFunctionExecutionStatus',
description: 'Status of the serverless function execution',
});
@ObjectType('ServerlessFunctionExecutionResult') @ObjectType('ServerlessFunctionExecutionResult')
export class ServerlessFunctionExecutionResultDto { export class ServerlessFunctionExecutionResultDTO {
@IsObject() @IsObject()
@Field(() => graphqlTypeJson, { @Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format', description: 'Execution result in JSON format',
nullable: true,
}) })
result: JSON; data?: JSON;
@Field({ description: 'Execution duration in milliseconds' })
duration: number;
@Field(() => ServerlessFunctionExecutionStatus, {
description: 'Execution status',
})
status: ServerlessFunctionExecutionStatus;
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, {
description: 'Execution error in JSON format',
nullable: true,
})
error?: {
errorType: string;
errorMessage: string;
stackTrace: string;
};
} }

View File

@ -36,7 +36,7 @@ registerEnumType(ServerlessFunctionSyncStatus, {
defaultResultSize: 10, defaultResultSize: 10,
maxResultsSize: 1000, maxResultsSize: 1000,
}) })
export class ServerlessFunctionDto { export class ServerlessFunctionDTO {
@IsUUID() @IsUUID()
@IsNotEmpty() @IsNotEmpty()
@IDField(() => UUIDScalarType) @IDField(() => UUIDScalarType)

View File

@ -13,7 +13,7 @@ import { ServerlessModule } from 'src/engine/integrations/serverless/serverless.
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver'; import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver';
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard'; import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity'; import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
@ -32,7 +32,7 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
resolvers: [ resolvers: [
{ {
EntityClass: ServerlessFunctionEntity, EntityClass: ServerlessFunctionEntity,
DTOClass: ServerlessFunctionDto, DTOClass: ServerlessFunctionDTO,
ServiceClass: ServerlessFunctionService, ServiceClass: ServerlessFunctionService,
pagingStrategy: PagingStrategies.CURSOR, pagingStrategy: PagingStrategies.CURSOR,
read: { read: {

View File

@ -14,8 +14,8 @@ import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modul
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input'; import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input'; import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input';
import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input'; import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input';
import { ServerlessFunctionExecutionResultDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; import { ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input'; import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
import { import {
ServerlessFunctionException, ServerlessFunctionException,
@ -49,7 +49,7 @@ export class ServerlessFunctionResolver {
} }
} }
@Mutation(() => ServerlessFunctionDto) @Mutation(() => ServerlessFunctionDTO)
async deleteOneServerlessFunction( async deleteOneServerlessFunction(
@Args('input') input: DeleteServerlessFunctionInput, @Args('input') input: DeleteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
@ -66,7 +66,7 @@ export class ServerlessFunctionResolver {
} }
} }
@Mutation(() => ServerlessFunctionDto) @Mutation(() => ServerlessFunctionDTO)
async updateOneServerlessFunction( async updateOneServerlessFunction(
@Args('input') @Args('input')
input: UpdateServerlessFunctionInput, input: UpdateServerlessFunctionInput,
@ -84,7 +84,7 @@ export class ServerlessFunctionResolver {
} }
} }
@Mutation(() => ServerlessFunctionDto) @Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunction( async createOneServerlessFunction(
@Args('input') @Args('input')
input: CreateServerlessFunctionInput, input: CreateServerlessFunctionInput,
@ -106,7 +106,7 @@ export class ServerlessFunctionResolver {
} }
} }
@Mutation(() => ServerlessFunctionDto) @Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunctionFromFile( async createOneServerlessFunctionFromFile(
@Args({ name: 'file', type: () => GraphQLUpload }) @Args({ name: 'file', type: () => GraphQLUpload })
file: FileUpload, file: FileUpload,
@ -127,7 +127,7 @@ export class ServerlessFunctionResolver {
} }
} }
@Mutation(() => ServerlessFunctionExecutionResultDto) @Mutation(() => ServerlessFunctionExecutionResultDTO)
async executeOneServerlessFunction( async executeOneServerlessFunction(
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput, @Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
@ -136,13 +136,11 @@ export class ServerlessFunctionResolver {
await this.checkFeatureFlag(workspaceId); await this.checkFeatureFlag(workspaceId);
const { id, payload } = executeServerlessFunctionInput; const { id, payload } = executeServerlessFunctionInput;
return { return await this.serverlessFunctionService.executeOne(
result: await this.serverlessFunctionService.executeOne( id,
id, workspaceId,
workspaceId, payload,
payload, );
),
};
} catch (error) { } catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error); serverlessFunctionGraphQLApiExceptionHandler(error);
} }

View File

@ -9,6 +9,7 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { ServerlessExecuteResult } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { ServerlessService } from 'src/engine/integrations/serverless/serverless.service'; import { ServerlessService } from 'src/engine/integrations/serverless/serverless.service';
import { import {
@ -41,7 +42,7 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
id: string, id: string,
workspaceId: string, workspaceId: string,
payload: object | undefined = undefined, payload: object | undefined = undefined,
) { ): Promise<ServerlessExecuteResult> {
const functionToExecute = await this.serverlessFunctionRepository.findOne({ const functionToExecute = await this.serverlessFunctionRepository.findOne({
where: { where: {
id, id,

View File

@ -26,9 +26,9 @@ import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/vie
import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity';
import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity';
import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity'; import { WebhookWorkspaceEntity } from 'src/modules/webhook/standard-objects/webhook.workspace-entity';
import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow-event-listener.workspace-entity'; import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-event-listener.workspace-entity';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow-version.workspace-entity'; import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
// TODO: Maybe we should automate this with the DiscoverService of Nest.JS // TODO: Maybe we should automate this with the DiscoverService of Nest.JS

View File

@ -17,7 +17,7 @@ import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/a
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.activityTarget, standardId: STANDARD_OBJECT_IDS.activityTarget,

View File

@ -20,7 +20,7 @@ import { ActivityWorkspaceEntity } from 'src/modules/activity/standard-objects/a
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({

View File

@ -19,7 +19,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({

View File

@ -4,6 +4,7 @@ import { CalendarModule } from 'src/modules/calendar/calendar.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module'; import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module'; import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { ViewModule } from 'src/modules/view/view.module'; import { ViewModule } from 'src/modules/view/view.module';
import { WorkflowModule } from 'src/modules/workflow/workflow.module';
@Module({ @Module({
imports: [ imports: [
@ -11,6 +12,7 @@ import { ViewModule } from 'src/modules/view/view.module';
CalendarModule, CalendarModule,
ConnectedAccountModule, ConnectedAccountModule,
ViewModule, ViewModule,
WorkflowModule,
], ],
providers: [], providers: [],
exports: [], exports: [],

View File

@ -19,7 +19,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync
import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity';
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({

View File

@ -13,7 +13,7 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { WORKFLOW_EVENT_LISTENER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { WORKFLOW_EVENT_LISTENER_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workflowEventListener, standardId: STANDARD_OBJECT_IDS.workflowEventListener,

View File

@ -13,21 +13,8 @@ import { WorkspaceJoinColumn } from 'src/engine/twenty-orm/decorators/workspace-
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator';
import { WORKFLOW_VERSION_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { WORKFLOW_VERSION_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids';
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
import { WorkflowTrigger } from 'src/modules/workflow/common/types/workflow-trigger.type';
export enum WorkflowTriggerType {
DATABASE_EVENT = 'DATABASE_EVENT',
}
export type WorkflowDatabaseEventTrigger = {
type: WorkflowTriggerType.DATABASE_EVENT;
settings: {
eventName: string;
triggerName: string;
};
};
export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workflowVersion, standardId: STANDARD_OBJECT_IDS.workflowVersion,
@ -60,7 +47,7 @@ export class WorkflowVersionWorkspaceEntity extends BaseWorkspaceEntity {
icon: 'IconPlayerPlay', icon: 'IconPlayerPlay',
}) })
@WorkspaceIsNullable() @WorkspaceIsNullable()
trigger: JSON | null; trigger: WorkflowTrigger | null;
// Relations // Relations
@WorkspaceRelation({ @WorkspaceRelation({

View File

@ -19,8 +19,8 @@ import { ActivityTargetWorkspaceEntity } from 'src/modules/activity/standard-obj
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow-event-listener.workspace-entity'; import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow-version.workspace-entity'; import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-event-listener.workspace-entity';
@WorkspaceEntity({ @WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workflow, standardId: STANDARD_OBJECT_IDS.workflow,

View File

@ -0,0 +1,14 @@
import { WorkflowSettingsType } from 'src/modules/workflow/common/types/workflow-settings.type';
export enum WorkflowActionType {
CODE = 'CODE',
}
export type WorkflowAction = {
name: string;
displayName: string;
type: WorkflowActionType;
valid: boolean;
settings: WorkflowSettingsType;
nextAction?: WorkflowAction;
};

View File

@ -0,0 +1,14 @@
export type WorkflowCodeSettingsType = {
serverlessFunctionId: string;
};
export type WorkflowSettingsType = {
errorHandlingOptions: {
retryOnFailure: {
value: boolean;
};
continueOnFailure: {
value: boolean;
};
};
} & WorkflowCodeSettingsType;

View File

@ -0,0 +1,28 @@
import { WorkflowAction } from 'src/modules/workflow/common/types/workflow-action.type';
export enum WorkflowTriggerType {
DATABASE_EVENT = 'DATABASE_EVENT',
MANUAL = 'MANUAL',
}
type BaseTrigger = {
type: WorkflowTriggerType;
input?: object;
nextAction?: WorkflowAction;
};
export type WorkflowDatabaseEventTrigger = BaseTrigger & {
type: WorkflowTriggerType.DATABASE_EVENT;
settings: {
eventName: string;
triggerName: string;
};
};
type WorkflowManualTrigger = BaseTrigger & {
type: WorkflowTriggerType.MANUAL;
};
export type WorkflowTrigger =
| WorkflowDatabaseEventTrigger
| WorkflowManualTrigger;

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { WorkflowCommonService } from 'src/modules/workflow/common/workflow-common.services';
@Module({
providers: [WorkflowCommonService],
exports: [WorkflowCommonService],
})
export class WorkflowCommonModule {}

View File

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/workflow-trigger.exception';
import { WorkflowTrigger } from 'src/modules/workflow/common/types/workflow-trigger.type';
@Injectable()
export class WorkflowCommonService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async getWorkflowVersion(
workspaceId: string,
workflowVersionId: string,
): Promise<
Omit<WorkflowVersionWorkspaceEntity, 'trigger'> & {
trigger: WorkflowTrigger;
}
> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
);
const workflowVersion = await workflowVersionRepository.findOne({
where: {
id: workflowVersionId,
},
});
if (!workflowVersion) {
throw new WorkflowTriggerException(
'Workflow version not found',
WorkflowTriggerExceptionCode.INVALID_INPUT,
);
}
if (!workflowVersion.trigger || !workflowVersion.trigger?.type) {
throw new WorkflowTriggerException(
'Workflow version does not contains trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
return { ...workflowVersion, trigger: workflowVersion.trigger };
}
}

View File

@ -0,0 +1,32 @@
import { Processor } from 'src/engine/integrations/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/integrations/message-queue/message-queue.constants';
import { Process } from 'src/engine/integrations/message-queue/decorators/process.decorator';
import { WorkflowRunnerService } from 'src/modules/workflow/workflow-runner/workflow-runner.service';
import { WorkflowCommonService } from 'src/modules/workflow/common/workflow-common.services';
type RunWorkflowJobData = { workspaceId: string; workflowVersionId: string };
@Processor(MessageQueue.workflowQueue)
export class WorkflowRunnerJob {
constructor(
private readonly workflowCommonService: WorkflowCommonService,
private readonly workflowRunnerService: WorkflowRunnerService,
) {}
@Process(WorkflowRunnerJob.name)
async handle({
workspaceId,
workflowVersionId,
}: RunWorkflowJobData): Promise<void> {
const workflowVersion = await this.workflowCommonService.getWorkflowVersion(
workspaceId,
workflowVersionId,
);
await this.workflowRunnerService.run({
action: workflowVersion.trigger.nextAction,
workspaceId,
payload: workflowVersion.trigger.input,
});
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WorkflowRunnerService } from 'src/modules/workflow/workflow-runner/workflow-runner.service';
import { WorkflowRunnerJob } from 'src/modules/workflow/workflow-runner/workflow-runner.job';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
@Module({
imports: [WorkflowCommonModule, ServerlessFunctionModule],
providers: [WorkflowRunnerService, WorkflowRunnerJob],
exports: [WorkflowRunnerService],
})
export class WorkflowRunnerModule {}

View File

@ -0,0 +1,82 @@
import { Injectable } from '@nestjs/common';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import {
WorkflowTriggerException,
WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/workflow-trigger.exception';
import {
WorkflowAction,
WorkflowActionType,
} from 'src/modules/workflow/common/types/workflow-action.type';
const MAX_RETRIES_ON_FAILURE = 3;
@Injectable()
export class WorkflowRunnerService {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
) {}
async run({
action,
workspaceId,
payload,
attemptCount = 1,
}: {
action?: WorkflowAction;
workspaceId: string;
payload?: object;
attemptCount?: number;
}) {
if (!action) {
return payload;
}
let result: object | undefined = undefined;
switch (action.type) {
case WorkflowActionType.CODE: {
const executionResult = await this.serverlessFunctionService.executeOne(
action.settings.serverlessFunctionId,
workspaceId,
payload,
);
if (executionResult.data) {
result = executionResult.data;
}
if (!executionResult.error) {
throw new Error('Execution result error, no data or error');
}
if (action.settings.errorHandlingOptions.continueOnFailure.value) {
result = payload;
break;
} else if (
action.settings.errorHandlingOptions.retryOnFailure.value &&
attemptCount < MAX_RETRIES_ON_FAILURE
) {
return await this.run({
action,
workspaceId,
payload,
attemptCount: attemptCount + 1,
});
} else {
return executionResult.error;
}
}
default:
throw new WorkflowTriggerException(
`Unknown action type '${action.type}'`,
WorkflowTriggerExceptionCode.INVALID_ACTION_TYPE,
);
}
return await this.run({
action: action.nextAction,
workspaceId,
payload: result,
});
}
}

View File

@ -11,4 +11,5 @@ export enum WorkflowTriggerExceptionCode {
INVALID_INPUT = 'INVALID_INPUT', INVALID_INPUT = 'INVALID_INPUT',
INVALID_WORKFLOW_TRIGGER = 'INVALID_WORKFLOW_TRIGGER', INVALID_WORKFLOW_TRIGGER = 'INVALID_WORKFLOW_TRIGGER',
INVALID_WORKFLOW_VERSION = 'INVALID_WORKFLOW_VERSION', INVALID_WORKFLOW_VERSION = 'INVALID_WORKFLOW_VERSION',
INVALID_ACTION_TYPE = 'INVALID_ACTION_TYPE',
} }

View File

@ -1,59 +1,51 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/standard-objects/workflow-event-listener.workspace-entity'; import { WorkflowEventListenerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-event-listener.workspace-entity';
import {
WorkflowDatabaseEventTrigger,
WorkflowTrigger,
WorkflowTriggerType,
WorkflowVersionWorkspaceEntity,
} from 'src/modules/workflow/standard-objects/workflow-version.workspace-entity';
import { import {
WorkflowTriggerException, WorkflowTriggerException,
WorkflowTriggerExceptionCode, WorkflowTriggerExceptionCode,
} from 'src/modules/workflow/workflow-trigger/workflow-trigger.exception'; } from 'src/modules/workflow/workflow-trigger/workflow-trigger.exception';
import {
WorkflowDatabaseEventTrigger,
WorkflowTriggerType,
} from 'src/modules/workflow/common/types/workflow-trigger.type';
import { WorkflowCommonService } from 'src/modules/workflow/common/workflow-common.services';
import { WorkflowRunnerService } from 'src/modules/workflow/workflow-runner/workflow-runner.service';
@Injectable() @Injectable()
export class WorkflowTriggerService { export class WorkflowTriggerService {
constructor( constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonService: WorkflowCommonService,
private readonly workflowRunnerService: WorkflowRunnerService,
) {} ) {}
async enableWorkflowTrigger(workspaceId: string, workflowVersionId: string) { async runWorkflow(workspaceId: string, workflowVersionId: string) {
const workflowVersionRepository = const workflowVersion = await this.workflowCommonService.getWorkflowVersion(
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>( workspaceId,
workspaceId, workflowVersionId,
'workflowVersion', );
);
const workflowVersion = await workflowVersionRepository.findOne({ return await this.workflowRunnerService.run({
where: { action: workflowVersion.trigger.nextAction,
id: workflowVersionId, workspaceId,
}, payload: workflowVersion.trigger.input,
}); });
}
if (!workflowVersion) { async enableWorkflowTrigger(workspaceId: string, workflowVersionId: string) {
throw new WorkflowTriggerException( const workflowVersion = await this.workflowCommonService.getWorkflowVersion(
'Workflow version not found', workspaceId,
WorkflowTriggerExceptionCode.INVALID_INPUT, workflowVersionId,
); );
}
const trigger = workflowVersion.trigger as unknown as WorkflowTrigger; switch (workflowVersion.trigger.type) {
if (!trigger || !trigger?.type) {
throw new WorkflowTriggerException(
'Workflow version does not contains trigger',
WorkflowTriggerExceptionCode.INVALID_WORKFLOW_VERSION,
);
}
switch (trigger.type) {
case WorkflowTriggerType.DATABASE_EVENT: case WorkflowTriggerType.DATABASE_EVENT:
await this.upsertWorkflowEventListener( await this.upsertWorkflowEventListener(
workspaceId, workspaceId,
workflowVersion.workflowId, workflowVersion.workflowId,
trigger, workflowVersion.trigger,
); );
break; break;
default: default:

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workflow-runner.module';
@Module({
imports: [WorkflowRunnerModule],
})
export class WorkflowModule {}