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.
---
.../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';