Refactor workflow-logic-function-interaction (#17699)

# Refactor workflow–logic function interaction

## Why

Workflow code steps and standalone logic functions shared the same build
layer and DB layer, which blurred two use cases: code steps belong to a
workflow version; standalone functions are deployable units. That made
workflow code steps harder to own and evolve.

## Goal

Treat code steps as **workflow-owned**: build and run them in workflow
context, and expose workflow-scoped APIs so the editor can load, test,
and save code step source without going through the generic
logic-function layer.
This commit is contained in:
Charles Bochet
2026-02-05 01:46:55 +01:00
committed by GitHub
parent 752359335e
commit 67a98f77e3
77 changed files with 1750 additions and 1336 deletions
@@ -1,6 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
@@ -14,6 +15,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import {
type WorkflowAction,
@@ -26,6 +28,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
let service: WorkflowVersionStepOperationsWorkspaceService;
let globalWorkspaceOrmManager: jest.Mocked<GlobalWorkspaceOrmManager>;
let logicFunctionService: jest.Mocked<LogicFunctionService>;
let applicationService: jest.Mocked<ApplicationService>;
let codeStepBuildService: jest.Mocked<CodeStepBuildService>;
let agentRepository: jest.Mocked<any>;
let roleTargetRepository: jest.Mocked<any>;
let roleRepository: jest.Mocked<any>;
@@ -33,12 +37,53 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
let workflowCommonWorkspaceService: jest.Mocked<WorkflowCommonWorkspaceService>;
let aiAgentRoleService: jest.Mocked<AiAgentRoleService>;
let workspaceCacheService: jest.Mocked<WorkspaceCacheService>;
let flatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
beforeEach(async () => {
applicationService = {
findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest
.fn()
.mockResolvedValue({
workspaceCustomFlatApplication: {
universalIdentifier: 'app-universal-id',
},
}),
} as unknown as jest.Mocked<ApplicationService>;
codeStepBuildService = {
seedCodeStepFiles: jest.fn().mockResolvedValue({
sourceHandlerPath: 'workflow/logic-fn-id/src/index.ts',
builtHandlerPath: 'workflow/logic-fn-id/src/index.mjs',
checksum: 'seed-checksum',
}),
copySourceAndBuiltForNewCodeStep: jest.fn().mockResolvedValue(undefined),
duplicateCodeStepLogicFunction: jest.fn().mockResolvedValue({
id: 'new-function-id',
name: 'Test Function',
description: 'Test Description',
workspaceId: mockWorkspaceId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
runtime: LogicFunctionRuntime.NODE22,
timeoutSeconds: 30,
sourceHandlerPath: 'src/index.ts',
builtHandlerPath: 'index.mjs',
handlerName: 'main',
checksum: null,
toolInputSchema: null,
isTool: false,
universalIdentifier: 'universal-id',
applicationId: 'application-id',
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
}),
} as unknown as jest.Mocked<CodeStepBuildService>;
logicFunctionService = {
createOne: jest.fn(),
destroyOne: jest.fn(),
duplicateLogicFunction: jest.fn(),
} as unknown as jest.Mocked<LogicFunctionService>;
agentRepository = {
@@ -86,6 +131,14 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
provide: LogicFunctionService,
useValue: logicFunctionService,
},
{
provide: ApplicationService,
useValue: applicationService,
},
{
provide: CodeStepBuildService,
useValue: codeStepBuildService,
},
{
provide: getRepositoryToken(AgentEntity),
useValue: agentRepository,
@@ -116,12 +169,12 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
useValue: (flatEntityMapsCacheService = {
flushFlatEntityMaps: jest.fn(),
getOrRecomputeManyOrAllFlatEntityMaps: jest
.fn()
.mockResolvedValue(createEmptyAllFlatEntityMaps()),
},
} as unknown as jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>),
},
],
}).compile();
@@ -310,6 +363,29 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
nextStepIds: ['next-step'],
} as unknown as WorkflowAction;
const mockExistingFlatLogicFunction: FlatLogicFunction = {
id: 'function-id',
name: 'Existing Function',
description: 'Existing Description',
workspaceId: mockWorkspaceId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
runtime: LogicFunctionRuntime.NODE22,
timeoutSeconds: 30,
sourceHandlerPath: 'workflow/function-id/src/index.ts',
builtHandlerPath: 'workflow/function-id/src/index.mjs',
handlerName: 'main',
checksum: 'existing-checksum',
toolInputSchema: null,
isTool: false,
universalIdentifier: 'existing-universal-id',
applicationId: 'application-id',
cronTriggerSettings: null,
databaseEventTriggerSettings: null,
httpRouteTriggerSettings: null,
};
const mockNewFlatLogicFunction: FlatLogicFunction = {
id: 'new-function-id',
name: 'Test Function',
@@ -333,7 +409,25 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
httpRouteTriggerSettings: null,
};
logicFunctionService.duplicateLogicFunction.mockResolvedValue(
const emptyMaps = createEmptyAllFlatEntityMaps();
const flatLogicFunctionMapsKey = 'flatLogicFunctionMaps' as const;
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValueOnce(
{
...emptyMaps,
[flatLogicFunctionMapsKey]: {
byUniversalIdentifier: {
'existing-universal-id': mockExistingFlatLogicFunction,
},
universalIdentifierById: {
'function-id': 'existing-universal-id',
},
universalIdentifiersByApplicationId: {},
},
},
);
logicFunctionService.createOne.mockResolvedValue(
mockNewFlatLogicFunction,
);
@@ -358,6 +452,12 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
expect(codeResult.settings.input.logicFunctionId).toBe('new-function-id');
expect(duplicateStep.nextStepIds).toEqual([]);
expect(
codeStepBuildService.duplicateCodeStepLogicFunction,
).toHaveBeenCalledWith({
existingLogicFunctionId: 'function-id',
workspaceId: mockWorkspaceId,
});
});
it('should duplicate non-code step', async () => {
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { CodeStepBuildService } from './services/code-step-build.service';
@Module({
imports: [
ApplicationModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
],
providers: [CodeStepBuildService],
exports: [CodeStepBuildService],
})
export class CodeStepBuildModule {}
@@ -0,0 +1,4 @@
export const CODE_STEP_DEFAULT_INPUT_SCHEMA = {
a: null,
b: null,
};
@@ -0,0 +1,8 @@
var main = async (params) => {
const { a, b } = params;
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
export {
main
};
@@ -0,0 +1,12 @@
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Rename the parameters and code below with your own logic
// This is just an example
const message = `Hello, input: ${a} and ${b}`;
return { message };
};
@@ -0,0 +1,222 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { LogicFunctionSourceBuilderService } from 'src/engine/core-modules/logic-function/logic-function-source-builder/logic-function-source-builder.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { LogicFunctionService } from 'src/engine/metadata-modules/logic-function/services/logic-function.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { findFlatLogicFunctionOrThrow } from 'src/engine/metadata-modules/logic-function/utils/find-flat-logic-function-or-throw.util';
import { fromCreateLogicFunctionInputToFlatLogicFunction } from 'src/engine/metadata-modules/logic-function/utils/from-create-logic-function-input-to-flat-logic-function.util';
import {
WorkflowActionType,
type WorkflowAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
const WORKFLOW_BASE_FOLDER_PREFIX = 'workflow';
@Injectable()
export class CodeStepBuildService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly logicFunctionService: LogicFunctionService,
private readonly applicationService: ApplicationService,
private readonly logicFunctionSourceBuilderService: LogicFunctionSourceBuilderService,
) {}
async duplicateCodeStepLogicFunction({
existingLogicFunctionId,
workspaceId,
}: {
existingLogicFunctionId: string;
workspaceId: string;
}): Promise<FlatLogicFunction> {
const { flatLogicFunctionMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
},
);
const existingLogicFunction = findFlatLogicFunctionOrThrow({
id: existingLogicFunctionId,
flatLogicFunctionMaps,
});
const resolvedOwnerFlatApplication = (
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
)
).workspaceCustomFlatApplication;
const applicationUniversalIdentifier =
resolvedOwnerFlatApplication.universalIdentifier;
const newId = v4();
const newFlatLogicFunction =
fromCreateLogicFunctionInputToFlatLogicFunction({
createLogicFunctionInput: {
name: existingLogicFunction.name,
description: existingLogicFunction.description ?? undefined,
timeoutSeconds: existingLogicFunction.timeoutSeconds,
id: newId,
},
workspaceId,
ownerFlatApplication: resolvedOwnerFlatApplication,
});
await this.logicFunctionSourceBuilderService.copySourceAndBuilt({
fromSourceHandlerPath: existingLogicFunction.sourceHandlerPath,
fromBuiltHandlerPath: existingLogicFunction.builtHandlerPath,
toSourceHandlerPath: newFlatLogicFunction.sourceHandlerPath,
toBuiltHandlerPath: newFlatLogicFunction.builtHandlerPath,
workspaceId,
applicationUniversalIdentifier,
});
const created = await this.logicFunctionService.createOne({
input: {
name: existingLogicFunction.name,
description: existingLogicFunction.description ?? undefined,
timeoutSeconds: existingLogicFunction.timeoutSeconds,
id: newFlatLogicFunction.id,
sourceHandlerPath: newFlatLogicFunction.sourceHandlerPath,
builtHandlerPath: newFlatLogicFunction.builtHandlerPath,
checksum: existingLogicFunction.checksum ?? undefined,
},
workspaceId,
ownerFlatApplication: resolvedOwnerFlatApplication,
});
if (!isDefined(created)) {
throw new Error(
'Failed to create logic function when duplicating code step',
);
}
return created;
}
async buildCodeStepsFromSourceForSteps({
workspaceId,
steps,
}: {
workspaceId: string;
steps: WorkflowAction[];
}): Promise<void> {
const codeSteps = steps.filter(
(
step,
): step is WorkflowAction & {
type: typeof WorkflowActionType.CODE;
settings: { input: { logicFunctionId: string } };
} =>
step.type === WorkflowActionType.CODE &&
isDefined(
(step.settings?.input as { logicFunctionId?: string })
?.logicFunctionId,
),
);
if (codeSteps.length === 0) {
return;
}
const { flatLogicFunctionMaps, flatApplicationMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps', 'flatApplicationMaps'],
},
);
for (const step of codeSteps) {
const logicFunctionId = step.settings.input.logicFunctionId;
const flatLogicFunction = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: logicFunctionId,
flatEntityMaps: flatLogicFunctionMaps,
});
if (
!isDefined(flatLogicFunction) ||
flatLogicFunction.deletedAt ||
!this.isWorkflowCodeStepLogicFunction(flatLogicFunction)
) {
continue;
}
const applicationUniversalIdentifier = isDefined(
flatLogicFunction.applicationId,
)
? flatApplicationMaps.byId[flatLogicFunction.applicationId]
?.universalIdentifier
: undefined;
if (!isDefined(applicationUniversalIdentifier)) {
continue;
}
const { checksum } =
await this.logicFunctionSourceBuilderService.buildFromSource({
sourceHandlerPath: flatLogicFunction.sourceHandlerPath,
builtHandlerPath: flatLogicFunction.builtHandlerPath,
workspaceId,
applicationUniversalIdentifier,
});
await this.logicFunctionService.updateChecksum({
id: flatLogicFunction.id,
checksum,
workspaceId,
});
}
}
isWorkflowCodeStepLogicFunction(
flatLogicFunction: FlatLogicFunction,
): boolean {
return (
flatLogicFunction.sourceHandlerPath.startsWith(
`${WORKFLOW_BASE_FOLDER_PREFIX}/`,
) ||
flatLogicFunction.builtHandlerPath.startsWith(
`${WORKFLOW_BASE_FOLDER_PREFIX}/`,
)
);
}
async getFlatLogicFunctionForCodeStepOrNull({
logicFunctionId,
workspaceId,
}: {
logicFunctionId: string;
workspaceId: string;
}): Promise<FlatLogicFunction | null> {
const { flatLogicFunctionMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatLogicFunctionMaps'],
},
);
const flatLogicFunction = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: logicFunctionId,
flatEntityMaps: flatLogicFunctionMaps,
});
if (
!isDefined(flatLogicFunction) ||
flatLogicFunction.deletedAt ||
!this.isWorkflowCodeStepLogicFunction(flatLogicFunction)
) {
return null;
}
return flatLogicFunction;
}
}
@@ -0,0 +1,12 @@
import { dirname } from 'path';
export const getLogicFunctionBaseFolderPath = (handlerPath: string): string => {
return dirname(dirname(handlerPath));
};
export const getRelativePathFromBase = (
handlerPath: string,
baseFolderPath: string,
): string => {
return handlerPath.replace(`${baseFolderPath}/`, '');
};
@@ -0,0 +1,45 @@
import fs from 'fs/promises';
import path from 'path';
import { ASSET_PATH } from 'src/constants/assets-path';
export type CodeStepSeedProjectFile = {
name: string;
path: string;
content: Buffer;
};
const getAllFiles = async (
rootDir: string,
dir: string = rootDir,
files: CodeStepSeedProjectFile[] = [],
): Promise<CodeStepSeedProjectFile[]> => {
const dirEntries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of dirEntries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await getAllFiles(rootDir, fullPath, files);
} else {
files.push({
path: path.relative(rootDir, dir),
name: entry.name,
content: await fs.readFile(fullPath),
});
}
}
return files;
};
export const getCodeStepSeedProjectFiles = async (): Promise<
CodeStepSeedProjectFile[]
> => {
const seedProjectPath = path.join(
ASSET_PATH,
'modules/workflow/workflow-builder/workflow-version-step/code-step/constants/seed-project',
);
return getAllFiles(seedProjectPath);
};
@@ -15,7 +15,7 @@ import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { SEED_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/seed-project-input-schema';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -28,6 +28,8 @@ import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-t
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { CODE_STEP_DEFAULT_INPUT_SCHEMA } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/constants/seed-project/code-step-default-input-schema';
import { CodeStepBuildService } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/services/code-step-build.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
@@ -65,6 +67,8 @@ export class WorkflowVersionStepOperationsWorkspaceService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly logicFunctionService: LogicFunctionService,
private readonly applicationService: ApplicationService,
private readonly codeStepBuildService: CodeStepBuildService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetEntity)
@@ -151,8 +155,12 @@ export class WorkflowVersionStepOperationsWorkspaceService {
switch (type) {
case WorkflowActionType.CODE: {
const logicFunctionId = id ?? v4();
// createOne handles seeding source files with default seed project
const newLogicFunction = await this.logicFunctionService.createOne({
input: {
id: logicFunctionId,
name: 'A Logic Function Code Workflow Step',
description: '',
},
@@ -184,7 +192,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
input: {
logicFunctionId: newLogicFunction.id,
logicFunctionInput: SEED_PROJECT_INPUT_SCHEMA,
logicFunctionInput: CODE_STEP_DEFAULT_INPUT_SCHEMA,
},
},
},
@@ -655,8 +663,8 @@ export class WorkflowVersionStepOperationsWorkspaceService {
switch (step.type) {
case WorkflowActionType.CODE: {
const newLogicFunction =
await this.logicFunctionService.duplicateLogicFunction({
id: step.settings.input.logicFunctionId,
await this.codeStepBuildService.duplicateCodeStepLogicFunction({
existingLogicFunctionId: step.settings.input.logicFunctionId,
workspaceId,
});
@@ -928,12 +936,10 @@ export class WorkflowVersionStepOperationsWorkspaceService {
switch (step.type) {
case WorkflowActionType.CODE: {
const newLogicFunction =
await this.logicFunctionService.createLogicFunctionFromExistingLogicFunctionById(
{
id: step.settings.input.logicFunctionId,
workspaceId,
},
);
await this.codeStepBuildService.duplicateCodeStepLogicFunction({
existingLogicFunctionId: step.settings.input.logicFunctionId,
workspaceId,
});
return {
...step,
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/code-step-build.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@@ -24,6 +26,8 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl
WorkflowSchemaModule,
LogicFunctionModule,
WorkflowCommonModule,
ApplicationModule,
CodeStepBuildModule,
AiAgentRoleModule,
WorkspaceCacheModule,
NestjsQueryTypeOrmModule.forFeature([
@@ -46,6 +50,7 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl
WorkflowVersionStepWorkspaceService,
WorkflowVersionStepOperationsWorkspaceService,
WorkflowVersionStepHelpersWorkspaceService,
CodeStepBuildModule,
],
})
export class WorkflowVersionStepModule {}