From 2c79093b74cfd1199696d8ec57de04d0764682f2 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 24 Jul 2026 05:57:40 +0530 Subject: [PATCH] feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206) ## Summary - Adds optional `roleUniversalIdentifier` on `AgentManifest` / `defineAgent` so apps can declaratively assign a role to an agent (same config shape as `defaultRoleUniversalIdentifier`). - Wires `agentUniversalIdentifier` as a sync many-to-one FK on `roleTarget`, and emits a deterministic `roleTarget` from the agent during app sync (create / update / delete). - Enables app agents (e.g. Slack assistant) to get a role on install without postInstall hooks or manual admin assignment. Review in cubic --- .../agents/__tests__/define-agent.spec.ts | 57 +++++ .../src/sdk/define/agents/define-agent.ts | 14 +- ...10-add-agent-foreign-key-to-role-target.ts | 22 ++ .../instance-commands.constant.ts | 2 + ...fest-to-universal-flat-role-target.util.ts | 29 +++ ...-all-universal-flat-entity-maps.service.ts | 14 ++ ...descriptor-by-universal-identifier.util.ts | 17 +- ...m-create-agent-input-to-flat-agent.util.ts | 1 + ...gent-input-to-flat-agent-to-update.util.ts | 2 + ...ce-flat-role-target-by-agent-id.service.ts | 46 ++-- ...ompare-and-stringify.constant.spec.ts.snap | 2 +- ...configuration-by-metadata-name.constant.ts | 2 +- ...ny-to-one-metadata-foreign-key.constant.ts | 3 + ...many-to-one-metadata-relations.constant.ts | 7 + ...a-related-metadata-names.util.spec.ts.snap | 1 + ...ata-names-children-first.util.spec.ts.snap | 2 +- ...pace-flat-role-target-map-cache.service.ts | 12 +- .../role-target/role-target.entity.ts | 8 + .../services/role-target.service.ts | 9 +- ...nput-to-flat-role-target-to-create.util.ts | 16 +- .../metadata-modules/role/role.module.ts | 3 + ...ce-migration-build-orchestrator.service.ts | 8 +- .../compute-ordered-migration-actions.util.ts | 17 +- ...le-target-assignation-availability.util.ts | 2 +- ...ole-target-targets-only-one-entity.util.ts | 6 +- ...eate-role-target-action-handler.service.ts | 5 +- ...sync-agent-role-target.integration-spec.ts | 226 ++++++++++++++++++ .../src/application/agentManifestType.ts | 1 + ...t-role-target-universal-identifier.util.ts | 14 ++ .../twenty-shared/src/application/index.ts | 1 + 30 files changed, 507 insertions(+), 42 deletions(-) create mode 100644 packages/twenty-sdk/src/sdk/define/agents/__tests__/define-agent.spec.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-agent-manifest-to-universal-flat-role-target.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-agent-role-target.integration-spec.ts create mode 100644 packages/twenty-shared/src/application/deterministic-identifier/get-role-target-universal-identifier.util.ts diff --git a/packages/twenty-sdk/src/sdk/define/agents/__tests__/define-agent.spec.ts b/packages/twenty-sdk/src/sdk/define/agents/__tests__/define-agent.spec.ts new file mode 100644 index 0000000000..50deb448ef --- /dev/null +++ b/packages/twenty-sdk/src/sdk/define/agents/__tests__/define-agent.spec.ts @@ -0,0 +1,57 @@ +import { defineAgent } from '@/sdk/define/agents/define-agent'; + +const VALID_AGENT_CONFIG = { + universalIdentifier: 'a29ae15d-dd16-4b99-bb6c-079842da55ab', + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + responseFormat: { type: 'text' as const }, +}; + +describe('defineAgent', () => { + it('should accept a valid agent config', () => { + const result = defineAgent(VALID_AGENT_CONFIG); + + expect(result.success).toBe(true); + expect(result.errors).toEqual([]); + expect(result.config).toEqual(VALID_AGENT_CONFIG); + }); + + it('should accept an optional roleUniversalIdentifier', () => { + const roleUniversalIdentifier = 'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940'; + const result = defineAgent({ + ...VALID_AGENT_CONFIG, + roleUniversalIdentifier, + }); + + expect(result.success).toBe(true); + expect(result.config?.roleUniversalIdentifier).toBe( + roleUniversalIdentifier, + ); + }); + + it('should error when roleUniversalIdentifier is not a valid UUID', () => { + const result = defineAgent({ + ...VALID_AGENT_CONFIG, + roleUniversalIdentifier: 'not-a-uuid', + }); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + `Agent 'sales-assistant' roleUniversalIdentifier must be a valid UUID`, + ); + }); + + it('should warn when responseFormat is missing', () => { + const { responseFormat: _responseFormat, ...configWithoutFormat } = + VALID_AGENT_CONFIG; + const result = defineAgent(configWithoutFormat); + + expect(result.success).toBe(true); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.stringContaining('has no responseFormat'), + ]), + ); + }); +}); diff --git a/packages/twenty-sdk/src/sdk/define/agents/define-agent.ts b/packages/twenty-sdk/src/sdk/define/agents/define-agent.ts index c72f988702..dd13b64611 100644 --- a/packages/twenty-sdk/src/sdk/define/agents/define-agent.ts +++ b/packages/twenty-sdk/src/sdk/define/agents/define-agent.ts @@ -1,6 +1,9 @@ +import { isNonEmptyString } from '@sniptt/guards'; +import { type AgentManifest } from 'twenty-shared/application'; +import { validate as uuidValidate } from 'uuid'; + import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type'; import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result'; -import { type AgentManifest } from 'twenty-shared/application'; export const defineAgent: DefineEntity = (config) => { const errors: string[] = []; @@ -28,5 +31,14 @@ export const defineAgent: DefineEntity = (config) => { ); } + if ( + isNonEmptyString(config.roleUniversalIdentifier) && + !uuidValidate(config.roleUniversalIdentifier) + ) { + errors.push( + `Agent '${config.name}' roleUniversalIdentifier must be a valid UUID`, + ); + } + return createValidationResult({ config, errors, warnings }); }; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target.ts new file mode 100644 index 0000000000..3cbf6180ca --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target.ts @@ -0,0 +1,22 @@ +import { type QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.24.0', 1784820332810) +export class AddAgentForeignKeyToRoleTargetFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."roleTarget" DROP CONSTRAINT IF EXISTS "FK_16433a32ab13a294569e52a10e0"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."roleTarget" ADD CONSTRAINT "FK_16433a32ab13a294569e52a10e0" FOREIGN KEY ("agentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE ON UPDATE NO ACTION', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."roleTarget" DROP CONSTRAINT IF EXISTS "FK_16433a32ab13a294569e52a10e0"', + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 8a6d9d692b..ebcd291fd0 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -122,6 +122,7 @@ import { BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2 import { UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784322591746-unlist-unclaimed-npm-application-registrations'; import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784650048045-add-statuses-to-billing-subscription-index'; import { AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784712843602-add-on-connect-logic-function-to-connection-provider'; +import { AddAgentForeignKeyToRoleTargetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -246,4 +247,5 @@ export const INSTANCE_COMMANDS = [ AddLogoFileIdToApplicationRegistration2_23FastInstanceCommand, AddStatusesToBillingSubscriptionIndexSlowInstanceCommand, AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand, + AddAgentForeignKeyToRoleTargetFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-agent-manifest-to-universal-flat-role-target.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-agent-manifest-to-universal-flat-role-target.util.ts new file mode 100644 index 0000000000..c371405e7e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-agent-manifest-to-universal-flat-role-target.util.ts @@ -0,0 +1,29 @@ +import { getRoleTargetUniversalIdentifier } from 'twenty-shared/application'; + +import { type UniversalFlatRoleTarget } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-role-target.type'; + +export const fromAgentManifestToUniversalFlatRoleTarget = ({ + agentUniversalIdentifier, + roleUniversalIdentifier, + applicationUniversalIdentifier, + now, +}: { + agentUniversalIdentifier: string; + roleUniversalIdentifier: string; + applicationUniversalIdentifier: string; + now: string; +}): UniversalFlatRoleTarget => { + return { + universalIdentifier: getRoleTargetUniversalIdentifier({ + applicationUniversalIdentifier, + agentUniversalIdentifier, + }), + applicationUniversalIdentifier, + roleUniversalIdentifier, + agentUniversalIdentifier, + userWorkspaceId: null, + apiKeyId: null, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts index 522094dba5..7fb43b9476 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/services/compute-application-manifest-all-universal-flat-entity-maps.service.ts @@ -8,6 +8,7 @@ import { MAX_CUSTOM_INDEXES_PER_OBJECT } from 'twenty-shared/constants'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { fromAgentManifestToUniversalFlatRoleTarget } from 'src/engine/core-modules/application/application-manifest/converters/from-agent-manifest-to-universal-flat-role-target.util'; import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util'; import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util'; @@ -366,6 +367,19 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService { universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatAgentMaps, }); + + if (isDefined(agentManifest.roleUniversalIdentifier)) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: fromAgentManifestToUniversalFlatRoleTarget({ + agentUniversalIdentifier: agentManifest.universalIdentifier, + roleUniversalIdentifier: agentManifest.roleUniversalIdentifier, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatRoleTargetMaps, + }); + } } for (const viewManifest of manifest.views ?? []) { diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/find-manifest-entity-descriptor-by-universal-identifier.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/find-manifest-entity-descriptor-by-universal-identifier.util.ts index bd04b3185c..9752d5ac11 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/find-manifest-entity-descriptor-by-universal-identifier.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/find-manifest-entity-descriptor-by-universal-identifier.util.ts @@ -1,4 +1,7 @@ -import { type Manifest } from 'twenty-shared/application'; +import { + getRoleTargetUniversalIdentifier, + type Manifest, +} from 'twenty-shared/application'; import { type AllMetadataName } from 'twenty-shared/metadata'; import { isDefined } from 'twenty-shared/utils'; @@ -227,7 +230,17 @@ const MANIFEST_ENTITY_REGISTRY: Record< }, roleTarget: { entityKind: 'role target', - getCandidates: () => NO_MANIFEST_CANDIDATES, + getCandidates: (manifest) => + (manifest.agents ?? []) + .filter((agent) => isDefined(agent.roleUniversalIdentifier)) + .map((agent) => ({ + universalIdentifier: getRoleTargetUniversalIdentifier({ + applicationUniversalIdentifier: + manifest.application.universalIdentifier, + agentUniversalIdentifier: agent.universalIdentifier, + }), + label: agent.label, + })), }, rolePermissionFlag: { entityKind: 'role permission flag', diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts index 557ae0ad6c..437e6114a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-create-agent-input-to-flat-agent.util.ts @@ -77,6 +77,7 @@ export const fromCreateAgentInputToFlatAgent = ({ roleUniversalIdentifier, userWorkspaceId: null, agentId, + agentUniversalIdentifier: flatAgentToCreate.universalIdentifier, apiKeyId: null, createdAt, updatedAt: createdAt, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts index 98ef79e0c3..2770ab1210 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/from-update-agent-input-to-flat-agent-to-update.util.ts @@ -66,6 +66,7 @@ const computeAgentFlatRoleTargetToUpdate = ({ ...existingRoleTarget, roleId, roleUniversalIdentifier: flatRole.universalIdentifier, + agentUniversalIdentifier: flatAgent.universalIdentifier, updatedAt, }, }; @@ -78,6 +79,7 @@ const computeAgentFlatRoleTargetToUpdate = ({ roleUniversalIdentifier: flatRole.universalIdentifier, userWorkspaceId: null, agentId: flatAgent.id, + agentUniversalIdentifier: flatAgent.universalIdentifier, apiKeyId: null, createdAt: updatedAt, updatedAt, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-role-target-by-agent-id.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-role-target-by-agent-id.service.ts index 20b5d2f19c..2527f2c0b1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-role-target-by-agent-id.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/services/workspace-flat-role-target-by-agent-id.service.ts @@ -7,6 +7,7 @@ import { IsNull, Not, Repository } from 'typeorm'; import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { FlatRoleTargetByAgentIdMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-role-target-by-agent-id-maps.type'; import { fromRoleTargetEntityToFlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; @@ -26,6 +27,8 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi private readonly applicationRepository: Repository, @InjectWorkspaceScopedRepository(RoleEntity) private readonly roleRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, ) { super(); } @@ -33,28 +36,36 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi async computeForCache( workspaceId: string, ): Promise { - const [roleTargetEntities, applications, roles] = await Promise.all([ - this.roleTargetRepository.find(workspaceId, { - where: { - agentId: Not(IsNull()), - }, - withDeleted: true, - }), - this.applicationRepository.find({ - where: { workspaceId }, - select: ['id', 'universalIdentifier'], - withDeleted: true, - }), - this.roleRepository.find(workspaceId, { - select: ['id', 'universalIdentifier'], - withDeleted: true, - }), - ]); + const [roleTargetEntities, applications, roles, agents] = await Promise.all( + [ + this.roleTargetRepository.find(workspaceId, { + where: { + agentId: Not(IsNull()), + }, + withDeleted: true, + }), + this.applicationRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.roleRepository.find(workspaceId, { + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.agentRepository.find(workspaceId, { + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + ], + ); const applicationIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(applications); const roleIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(roles); + const agentIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(agents); const flatRoleTargetByAgentIdMaps: FlatRoleTargetByAgentIdMaps = {}; @@ -66,6 +77,7 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi entity: roleTargetEntity, applicationIdToUniversalIdentifierMap, roleIdToUniversalIdentifierMap, + agentIdToUniversalIdentifierMap, }); flatRoleTargetByAgentIdMaps[roleTargetEntity.agentId] = flatRoleTarget; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index 995a1f3710..e9a89b92c4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -282,7 +282,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "roleUniversalIdentifier", "userWorkspaceId", "apiKeyId", - "agentId", + "agentUniversalIdentifier", ], "propertiesToStringify": [], }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index d62737d54a..89eb1476b1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -885,7 +885,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { agentId: { toCompare: true, toStringify: false, - universalProperty: undefined, + universalProperty: 'agentUniversalIdentifier', }, createdAt: { toCompare: false, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts index b757c39fb0..f249be0980 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts @@ -170,6 +170,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = { role: { foreignKey: 'roleId', }, + agent: { + foreignKey: 'agentId', + }, apiKey: null, workspace: null, application: null, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts index 7b42c50c2e..a3dfaf58de 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts @@ -303,6 +303,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = { isNullable: false, universalForeignKey: 'roleUniversalIdentifier', }, + agent: { + metadataName: 'agent', + foreignKey: 'agentId', + inverseOneToManyProperty: null, + isNullable: true, + universalForeignKey: 'agentUniversalIdentifier', + }, apiKey: null, workspace: null, application: null, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 8e94550d7e..aa2ad070e3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -120,6 +120,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo exports[`getMetadataRelatedMetadataNames should return related metadata names for roleTarget 1`] = ` [ "role", + "agent", ] `; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index ffda9ae514..ff6196a100 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -12,10 +12,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with "objectPermission", "pageLayoutWidget", "rolePermissionFlag", + "roleTarget", "viewSort", "index", "pageLayout", - "roleTarget", "rowLevelPermissionPredicateGroup", "viewGroup", "agent", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/services/workspace-flat-role-target-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/services/workspace-flat-role-target-map-cache.service.ts index 764eceb3bd..3e602fcb07 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role-target/services/workspace-flat-role-target-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role-target/services/workspace-flat-role-target-map-cache.service.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; import { type FlatRoleTargetMaps } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target-maps.type'; import { fromRoleTargetEntityToFlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/utils/from-role-target-entity-to-flat-role-target.util'; @@ -27,12 +28,14 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid private readonly applicationRepository: Repository, @InjectWorkspaceScopedRepository(RoleEntity) private readonly roleRepository: WorkspaceScopedRepository, + @InjectWorkspaceScopedRepository(AgentEntity) + private readonly agentRepository: WorkspaceScopedRepository, ) { super(); } async computeForCache(workspaceId: string): Promise { - const [roleTargets, applications, roles] = await Promise.all([ + const [roleTargets, applications, roles, agents] = await Promise.all([ this.roleTargetRepository.find(workspaceId, { withDeleted: true, }), @@ -45,12 +48,18 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid select: ['id', 'universalIdentifier'], withDeleted: true, }), + this.agentRepository.find(workspaceId, { + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), ]); const applicationIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(applications); const roleIdToUniversalIdentifierMap = createIdToUniversalIdentifierMap(roles); + const agentIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(agents); const flatRoleTargetMaps = createEmptyFlatEntityMaps(); @@ -59,6 +68,7 @@ export class WorkspaceFlatRoleTargetMapCacheService extends WorkspaceCacheProvid entity: roleTargetEntity, applicationIdToUniversalIdentifierMap, roleIdToUniversalIdentifierMap, + agentIdToUniversalIdentifierMap, }); addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts index 95eb8cd6de..6633f8b590 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/role-target.entity.ts @@ -13,6 +13,7 @@ import { } from 'typeorm'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; +import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @@ -50,6 +51,13 @@ export class RoleTargetEntity extends SyncableEntity { @Column({ nullable: true, type: 'uuid' }) agentId: string | null; + @ManyToOne(() => AgentEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'agentId' }) + agent: Relation | null; + @Column({ nullable: true, type: 'uuid' }) apiKeyId: string | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/services/role-target.service.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/services/role-target.service.ts index 03290ee1ac..886fc97c62 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/services/role-target.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/services/role-target.service.ts @@ -56,7 +56,12 @@ export class RoleTargetService { return []; } - const { flatRoleTargetMaps, flatApplicationMaps, flatRoleMaps } = + const { + flatRoleTargetMaps, + flatApplicationMaps, + flatRoleMaps, + flatAgentMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, @@ -64,6 +69,7 @@ export class RoleTargetService { 'flatRoleTargetMaps', 'flatApplicationMaps', 'flatRoleMaps', + 'flatAgentMaps', ], }, ); @@ -88,6 +94,7 @@ export class RoleTargetService { createRoleTargetInput, flatRoleTargetMaps, flatRoleMaps, + flatAgentMaps, workspaceId, flatApplication: flatApplication ?? workspaceCustomFlatApplication, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts index 953681edc3..3be4cd450f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role-target/utils/from-create-role-target-input-to-flat-role-target-to-create.util.ts @@ -3,6 +3,7 @@ import { v4 } from 'uuid'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type'; import { findFlatRoleTargetFromForeignKey } from 'src/engine/metadata-modules/flat-role-target/utils/find-flat-role-target-from-foreign-key.util'; @@ -13,12 +14,16 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({ workspaceId, flatRoleTargetMaps, flatRoleMaps, + flatAgentMaps, flatApplication, }: { createRoleTargetInput: CreateRoleTargetInput; workspaceId: string; flatApplication: FlatApplication; -} & Pick): { +} & Pick< + AllFlatEntityMaps, + 'flatRoleTargetMaps' | 'flatRoleMaps' | 'flatAgentMaps' +>): { flatRoleTargetToCreate: FlatRoleTarget; flatRoleTargetsToDelete: FlatRoleTarget[]; } => { @@ -34,12 +39,21 @@ export const fromCreateRoleTargetInputToFlatRoleTargetToCreate = ({ }, ); + const agentUniversalIdentifier = + targetMetadataForeignKey === 'agentId' + ? findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatAgentMaps, + flatEntityId: targetId, + }).universalIdentifier + : null; + const flatRoleTargetToCreate: FlatRoleTarget = { id: v4(), roleId, roleUniversalIdentifier, userWorkspaceId: null, agentId: null, + agentUniversalIdentifier, apiKeyId: null, createdAt: now.toISOString(), updatedAt: now.toISOString(), diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts index ee931545bd..98e6a9e1a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.module.ts @@ -7,6 +7,7 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati import { FileModule } from 'src/engine/core-modules/file/file.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; +import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; import { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; @@ -37,6 +38,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace imports: [ TypeOrmModule.forFeature([ ApplicationEntity, + AgentEntity, RoleEntity, RoleTargetEntity, ObjectPermissionEntity, @@ -72,6 +74,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceRolesPermissionsCacheService, provideWorkspaceScopedRepository(RoleEntity), provideWorkspaceScopedRepository(RoleTargetEntity), + provideWorkspaceScopedRepository(AgentEntity), provideWorkspaceScopedRepository(ObjectPermissionEntity), provideWorkspaceScopedRepository(FieldPermissionEntity), provideWorkspaceScopedRepository(RowLevelPermissionPredicateEntity), diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 46f93caac5..6d325230f8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -243,14 +243,14 @@ export class WorkspaceMigrationBuildOrchestratorService { ALL_METADATA_NAME.rolePermissionFlag, workspaceMigrationRolePermissionFlagActionsBuilderService, ), - createEntityActionsBuilderTask( - ALL_METADATA_NAME.roleTarget, - workspaceMigrationRoleTargetActionsBuilderService, - ), createEntityActionsBuilderTask( ALL_METADATA_NAME.agent, workspaceMigrationAgentActionsBuilderService, ), + createEntityActionsBuilderTask( + ALL_METADATA_NAME.roleTarget, + workspaceMigrationRoleTargetActionsBuilderService, + ), createEntityActionsBuilderTask( ALL_METADATA_NAME.skill, workspaceMigrationSkillActionsBuilderService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts index 4418d956fe..5160087ae0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/compute-ordered-migration-actions.util.ts @@ -58,8 +58,17 @@ export const computeOrderedMigrationActions = ( ...aggregatedOrchestratorActionsReport.role.update, /// - // Role targets + // Role targets delete before agents (roleTarget may FK to agent) ...aggregatedOrchestratorActionsReport.roleTarget.delete, + /// + + // Agents (must exist before roleTarget create/update that reference them) + ...aggregatedOrchestratorActionsReport.agent.delete, + ...aggregatedOrchestratorActionsReport.agent.create, + ...aggregatedOrchestratorActionsReport.agent.update, + /// + + // Role targets create/update after agents exist ...aggregatedOrchestratorActionsReport.roleTarget.create, ...aggregatedOrchestratorActionsReport.roleTarget.update, /// @@ -85,12 +94,6 @@ export const computeOrderedMigrationActions = ( ...aggregatedOrchestratorActionsReport.rolePermissionFlag.update, /// - // Agents - ...aggregatedOrchestratorActionsReport.agent.delete, - ...aggregatedOrchestratorActionsReport.agent.create, - ...aggregatedOrchestratorActionsReport.agent.update, - /// - // Skills ...aggregatedOrchestratorActionsReport.skill.delete, ...aggregatedOrchestratorActionsReport.skill.create, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-assignation-availability.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-assignation-availability.util.ts index ffeaef24cc..805b2716d0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-assignation-availability.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-assignation-availability.util.ts @@ -18,7 +18,7 @@ export const validateFlatRoleTargetAssignationAvailability = ({ const roleLabel = flatRole.label; - if (isDefined(flatRoleTarget.agentId)) { + if (isDefined(flatRoleTarget.agentUniversalIdentifier)) { if (!flatRole.canBeAssignedToAgents) { errors.push({ code: RoleTargetExceptionCode.ROLE_CANNOT_BE_ASSIGNED_TO_ENTITY, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-targets-only-one-entity.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-targets-only-one-entity.util.ts index d22cc1cc5d..8b128a4a21 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-targets-only-one-entity.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/utils/validate-flat-role-target-targets-only-one-entity.util.ts @@ -15,14 +15,14 @@ export const validateFlatRoleTargetTargetsOnlyOneEntity = ({ const definedIdentifiersCount = [ isDefined(flatRoleTarget.apiKeyId), isDefined(flatRoleTarget.userWorkspaceId), - isDefined(flatRoleTarget.agentId), + isDefined(flatRoleTarget.agentUniversalIdentifier), ].filter(Boolean).length; if (definedIdentifiersCount !== 1) { errors.push({ code: RoleTargetExceptionCode.ROLE_TARGET_MISSING_IDENTIFIER, - message: t`Role target must have exactly one of: apiKeyId, userWorkspaceId, or agentId`, - userFriendlyMessage: msg`Role target must have exactly one of: apiKeyId, userWorkspaceId, or agentId`, + message: t`Role target must have exactly one of: apiKeyId, userWorkspaceId, or agentUniversalIdentifier`, + userFriendlyMessage: msg`Role target must have exactly one of: apiKeyId, userWorkspaceId, or agentUniversalIdentifier`, }); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/role-target/services/create-role-target-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/role-target/services/create-role-target-action-handler.service.ts index a33a81d099..9af653767d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/role-target/services/create-role-target-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/role-target/services/create-role-target-action-handler.service.ts @@ -25,11 +25,13 @@ export class CreateRoleTargetActionHandlerService extends WorkspaceMigrationRunn allFlatEntityMaps, flatApplication, workspaceId, + preallocatedIdByUniversalIdentifierByMetadataName, }: WorkspaceMigrationActionRunnerArgs): Promise { - const { roleId } = resolveUniversalRelationIdentifiersToIds({ + const { roleId, agentId } = resolveUniversalRelationIdentifiersToIds({ flatEntityMaps: allFlatEntityMaps, metadataName: action.metadataName, universalForeignKeyValues: action.flatEntity, + preallocatedIdByUniversalIdentifierByMetadataName, }); const emptyUniversalForeignKeyAggregators = @@ -42,6 +44,7 @@ export class CreateRoleTargetActionHandlerService extends WorkspaceMigrationRunn flatEntity: { ...action.flatEntity, roleId, + agentId, applicationId: flatApplication.id, id: action.id ?? v4(), workspaceId, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-agent-role-target.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-agent-role-target.integration-spec.ts new file mode 100644 index 0000000000..cd441cdb47 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-sync-agent-role-target.integration-spec.ts @@ -0,0 +1,226 @@ +import { findAgents } from 'test/integration/metadata/suites/agent/utils/find-agents.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { + getRoleTargetUniversalIdentifier, + type Manifest, +} from 'twenty-shared/application'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const TEST_SECOND_ROLE_ID = uuidv4(); +const TEST_AGENT_ID = uuidv4(); + +const AGENT_GQL_FIELDS = 'id name label roleId applicationId'; + +const buildManifest = ( + overrides?: Partial>, +) => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + roles: [ + { + universalIdentifier: TEST_ROLE_ID, + label: 'Test Role', + description: 'A test role', + canBeAssignedToAgents: true, + }, + { + universalIdentifier: TEST_SECOND_ROLE_ID, + label: 'Second Agent Role', + description: 'Another agent-assignable role', + canBeAssignedToAgents: true, + }, + ], + ...overrides, + }, + }); + +const findTestApplicationId = async (): Promise => { + const rows = await global.testDataSource.query( + `SELECT id FROM core."application" WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + + return rows[0].id; +}; + +const findAppAgent = async () => { + const testApplicationId = await findTestApplicationId(); + const { data } = await findAgents({ + gqlFields: AGENT_GQL_FIELDS, + expectToFail: false, + input: undefined, + }); + + return data.findManyAgents.find( + (agent) => + agent.applicationId === testApplicationId && + agent.name === 'sales-assistant', + ); +}; + +describe('Manifest sync - agent roleTarget', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Application', + description: 'App for testing agent roleTarget manifest sync', + sourcePath: 'test-manifest-sync-agent-role-target', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('should create a roleTarget when an agent declares roleUniversalIdentifier', async () => { + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + roleUniversalIdentifier: TEST_ROLE_ID, + }, + ], + }), + expectToFail: false, + }); + + const agent = await findAppAgent(); + + expect(agent).toBeDefined(); + expect(agent?.roleId).toBeDefined(); + + const rows = await global.testDataSource.query( + ` + SELECT "roleId", "agentId", "universalIdentifier" + FROM "core"."roleTarget" + WHERE "agentId" = $1 + `, + [agent?.id], + ); + + expect(rows).toHaveLength(1); + expect(rows[0].universalIdentifier).toBe( + getRoleTargetUniversalIdentifier({ + applicationUniversalIdentifier: TEST_APP_ID, + agentUniversalIdentifier: TEST_AGENT_ID, + }), + ); + }, 60000); + + it('should update the roleTarget when roleUniversalIdentifier changes', async () => { + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + roleUniversalIdentifier: TEST_ROLE_ID, + }, + ], + }), + expectToFail: false, + }); + + const agentAfterFirstSync = await findAppAgent(); + const firstRoleId = agentAfterFirstSync?.roleId; + + expect(firstRoleId).toBeDefined(); + + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + roleUniversalIdentifier: TEST_SECOND_ROLE_ID, + }, + ], + }), + expectToFail: false, + }); + + const agentAfterSecondSync = await findAppAgent(); + + expect(agentAfterSecondSync?.roleId).toBeDefined(); + expect(agentAfterSecondSync?.roleId).not.toBe(firstRoleId); + + const rows = await global.testDataSource.query( + ` + SELECT "roleId", "agentId" + FROM "core"."roleTarget" + WHERE "agentId" = $1 + `, + [agentAfterSecondSync?.id], + ); + + expect(rows).toHaveLength(1); + expect(rows[0].roleId).toBe(agentAfterSecondSync?.roleId); + }, 60000); + + it('should delete the roleTarget when roleUniversalIdentifier is removed', async () => { + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + roleUniversalIdentifier: TEST_ROLE_ID, + }, + ], + }), + expectToFail: false, + }); + + const agentAfterFirstSync = await findAppAgent(); + + expect(agentAfterFirstSync?.roleId).toBeDefined(); + + await syncApplication({ + manifest: buildManifest({ + agents: [ + { + universalIdentifier: TEST_AGENT_ID, + name: 'sales-assistant', + label: 'Sales Assistant', + prompt: 'You are a sales assistant.', + }, + ], + }), + expectToFail: false, + }); + + const agentAfterSecondSync = await findAppAgent(); + + expect(agentAfterSecondSync?.roleId).toBeNull(); + + const rows = await global.testDataSource.query( + ` + SELECT "id" + FROM "core"."roleTarget" + WHERE "agentId" = $1 + `, + [agentAfterSecondSync?.id], + ); + + expect(rows).toHaveLength(0); + }, 60000); +}); diff --git a/packages/twenty-shared/src/application/agentManifestType.ts b/packages/twenty-shared/src/application/agentManifestType.ts index 9d3cef9591..afe3ba63d6 100644 --- a/packages/twenty-shared/src/application/agentManifestType.ts +++ b/packages/twenty-shared/src/application/agentManifestType.ts @@ -9,4 +9,5 @@ export type AgentManifest = SyncableEntityOptions & { prompt: string; modelId?: string; responseFormat?: AgentResponseFormat; + roleUniversalIdentifier?: string; }; diff --git a/packages/twenty-shared/src/application/deterministic-identifier/get-role-target-universal-identifier.util.ts b/packages/twenty-shared/src/application/deterministic-identifier/get-role-target-universal-identifier.util.ts new file mode 100644 index 0000000000..0e07b03f02 --- /dev/null +++ b/packages/twenty-shared/src/application/deterministic-identifier/get-role-target-universal-identifier.util.ts @@ -0,0 +1,14 @@ +import { computeDeterministicUuid } from '@/application/deterministic-identifier/compute-deterministic-uuid.util'; + +export const getRoleTargetUniversalIdentifier = ({ + applicationUniversalIdentifier, + agentUniversalIdentifier, +}: { + applicationUniversalIdentifier: string; + agentUniversalIdentifier: string; +}): string => + computeDeterministicUuid({ + entityNamespace: 'roleTarget', + value: agentUniversalIdentifier, + applicationUniversalIdentifier, + }); diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index e89ae1ffc9..866bd62125 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -73,6 +73,7 @@ export { export { getPageLayoutWidgetUniversalIdentifier } from './deterministic-identifier/get-page-layout-widget-universal-identifier.util'; export { getPermissionFlagUniversalIdentifier } from './deterministic-identifier/get-permission-flag-universal-identifier.util'; export { getRolePermissionFlagUniversalIdentifier } from './deterministic-identifier/get-role-permission-flag-universal-identifier.util'; +export { getRoleTargetUniversalIdentifier } from './deterministic-identifier/get-role-target-universal-identifier.util'; export { getRoleUniversalIdentifier } from './deterministic-identifier/get-role-universal-identifier.util'; export { getSearchFieldUniversalIdentifier } from './deterministic-identifier/get-search-field-universal-identifier.util'; export { getSelectOptionUniversalIdentifier } from './deterministic-identifier/get-select-option-universal-identifier.util';