refactor(workflow-tools): reorganize to one file per tool with co-located schemas (#16313)

## Summary

Reorganizes workflow tools to improve maintainability and
discoverability by having one file per tool with co-located input
schemas.

## Changes

- Create individual tool files in `tools/` directory (11 files)
- Co-locate input schemas with their tool implementations
- Add shared types file for dependencies and context
- Simplify workspace service to aggregate tool factories
- Remove centralized `schemas/` directory

## New Structure

```
workflow-tools/
├── services/
│   └── workflow-tool.workspace-service.ts
├── tools/
│   ├── activate-workflow-version.tool.ts
│   ├── compute-step-output-schema.tool.ts
│   ├── create-complete-workflow.tool.ts
│   ├── create-draft-from-workflow-version.tool.ts
│   ├── create-workflow-version-edge.tool.ts
│   ├── create-workflow-version-step.tool.ts
│   ├── deactivate-workflow-version.tool.ts
│   ├── delete-workflow-version-edge.tool.ts
│   ├── delete-workflow-version-step.tool.ts
│   ├── update-workflow-version-positions.tool.ts
│   └── update-workflow-version-step.tool.ts
├── types/
│   └── workflow-tool-dependencies.type.ts
└── workflow-tools.module.ts
```

## Benefits

- **Co-location**: Schema and tool logic are in the same file
- **Single responsibility**: Each file handles one tool
- **Easier maintenance**: Changes to a tool only touch one file
- **Better discoverability**: File names match tool names
This commit is contained in:
Félix Malfait
2025-12-04 21:06:49 +01:00
committed by GitHub
parent 1991ee850e
commit 9cecbaebc3
46 changed files with 2323 additions and 1186 deletions
@@ -0,0 +1,140 @@
import { type ToolSet } from 'ai';
import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { type UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import {
type ObjectWithPermission,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
// Dependencies required by the direct record tools factory
export type DirectRecordToolsDeps = {
createRecordService: CreateRecordService;
updateRecordService: UpdateRecordService;
deleteRecordService: DeleteRecordService;
findRecordsService: FindRecordsService;
};
export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
return (
{
objectMetadata,
restrictedFields,
canCreate,
canRead,
canUpdate,
canDelete,
}: ObjectWithPermission,
context: ToolGeneratorContext,
): ToolSet => {
const tools: ToolSet = {};
if (canRead) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { limit, offset, orderBy, ...filter } = parameters.input;
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter,
orderBy,
limit,
offset,
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
tools[`find_one_${objectMetadata.nameSingular}`] = {
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
inputSchema: FindOneToolInputSchema,
execute: async (parameters) => {
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.input.id } },
limit: 1,
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canCreate) {
tools[`create_${objectMetadata.nameSingular}`] = {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
inputSchema: generateCreateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
return deps.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
});
},
};
}
if (canUpdate) {
tools[`update_${objectMetadata.nameSingular}`] = {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
inputSchema: generateUpdateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { id, ...allFields } = parameters.input;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return deps.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canDelete) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
inputSchema: SoftDeleteToolInputSchema,
execute: async (parameters) => {
return deps.deleteRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.input.id,
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
},
};
}
return tools;
};
};
@@ -0,0 +1,178 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import {
type ObjectWithPermission,
type ToolFactory,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import {
type ToolHints,
type ToolOperation,
} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class PerObjectToolGeneratorService {
private readonly logger = new Logger(PerObjectToolGeneratorService.name);
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
// Generate tools by iterating over workspace objects once and applying all factories
async generate(
context: ToolGeneratorContext,
factories: ToolFactory[],
toolHints?: ToolHints,
): Promise<ToolSet> {
const objects = await this.getFilteredObjectsWithPermissions(
context.workspaceId,
context.rolePermissionConfig,
toolHints,
);
const tools: ToolSet = {};
for (const objectWithPermission of objects) {
for (const factory of factories) {
Object.assign(tools, factory(objectWithPermission, context));
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools from ${factories.length} factories for ${objects.length} objects`,
);
return tools;
}
// Get workspace objects with their permissions, filtered by toolHints
async getFilteredObjectsWithPermissions(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
toolHints?: ToolHints,
): Promise<ObjectWithPermission[]> {
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
let objectPermissions;
if ('unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]];
} else {
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
} else if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
objectPermissions =
allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
} else {
return [];
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const allObjectMetadata = allFlatObjects.map((flatObject) => ({
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
}));
// Filter out workflow-related objects
let filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
);
// Apply toolHints filtering if provided
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
const relevantSet = new Set(toolHints.relevantObjects);
const originalCount = filteredObjectMetadata.length;
filteredObjectMetadata = filteredObjectMetadata.filter(
(obj) =>
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
);
this.logger.log(
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
);
if (filteredObjectMetadata.length === 0) {
this.logger.warn(
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
);
}
}
// Map to ObjectWithPermission
const result: ObjectWithPermission[] = [];
const operationsSet = toolHints?.operations
? new Set(toolHints.operations)
: null;
const shouldIncludeOperation = (operation: ToolOperation) =>
!operationsSet || operationsSet.has(operation);
for (const objectMetadata of filteredObjectMetadata) {
const permission = objectPermissions[objectMetadata.id];
if (!permission) {
continue;
}
result.push({
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate:
shouldIncludeOperation('create') && permission.canUpdateObjectRecords,
canRead:
shouldIncludeOperation('find') && permission.canReadObjectRecords,
canUpdate:
shouldIncludeOperation('update') && permission.canUpdateObjectRecords,
canDelete:
shouldIncludeOperation('delete') &&
permission.canSoftDeleteObjectRecords,
});
}
if (operationsSet) {
this.logger.log(
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
);
}
return result;
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { PerObjectToolGeneratorService } from './services/per-object-tool-generator.service';
@Module({
imports: [WorkspaceCacheModule, WorkspaceManyOrAllFlatEntityMapsCacheModule],
providers: [PerObjectToolGeneratorService],
exports: [PerObjectToolGeneratorService],
})
export class ToolGeneratorModule {}
@@ -0,0 +1,31 @@
import { type ToolSet } from 'ai';
import {
type ActorMetadata,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
// Object metadata enriched with permission information
export type ObjectWithPermission = {
objectMetadata: ObjectMetadataForToolSchema;
restrictedFields: RestrictedFieldsPermissions;
canCreate: boolean;
canRead: boolean;
canUpdate: boolean;
canDelete: boolean;
};
// Context passed to tool factories
export type ToolGeneratorContext = {
workspaceId: string;
rolePermissionConfig: RolePermissionConfig;
actorContext?: ActorMetadata;
};
// Factory function type for generating tools from an object
export type ToolFactory = (
objectWithPermission: ObjectWithPermission,
context: ToolGeneratorContext,
) => ToolSet;