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.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-24 05:57:40 +05:30
committed by GitHub
parent 8b7ac02464
commit 2c79093b74
30 changed files with 507 additions and 42 deletions
@@ -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'),
]),
);
});
});
@@ -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<AgentManifest> = (config) => {
const errors: string[] = [];
@@ -28,5 +31,14 @@ export const defineAgent: DefineEntity<AgentManifest> = (config) => {
);
}
if (
isNonEmptyString(config.roleUniversalIdentifier) &&
!uuidValidate(config.roleUniversalIdentifier)
) {
errors.push(
`Agent '${config.name}' roleUniversalIdentifier must be a valid UUID`,
);
}
return createValidationResult({ config, errors, warnings });
};
@@ -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<void> {
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<void> {
await queryRunner.query(
'ALTER TABLE "core"."roleTarget" DROP CONSTRAINT IF EXISTS "FK_16433a32ab13a294569e52a10e0"',
);
}
}
@@ -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,
];
@@ -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,
};
};
@@ -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 ?? []) {
@@ -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',
@@ -77,6 +77,7 @@ export const fromCreateAgentInputToFlatAgent = ({
roleUniversalIdentifier,
userWorkspaceId: null,
agentId,
agentUniversalIdentifier: flatAgentToCreate.universalIdentifier,
apiKeyId: null,
createdAt,
updatedAt: createdAt,
@@ -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,
@@ -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<ApplicationEntity>,
@InjectWorkspaceScopedRepository(RoleEntity)
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
@InjectWorkspaceScopedRepository(AgentEntity)
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
) {
super();
}
@@ -33,28 +36,36 @@ export class WorkspaceFlatRoleTargetByAgentIdService extends WorkspaceCacheProvi
async computeForCache(
workspaceId: string,
): Promise<FlatRoleTargetByAgentIdMaps> {
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;
@@ -282,7 +282,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"roleUniversalIdentifier",
"userWorkspaceId",
"apiKeyId",
"agentId",
"agentUniversalIdentifier",
],
"propertiesToStringify": [],
},
@@ -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,
@@ -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,
@@ -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,
@@ -120,6 +120,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
exports[`getMetadataRelatedMetadataNames should return related metadata names for roleTarget 1`] = `
[
"role",
"agent",
]
`;
@@ -12,10 +12,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
"objectPermission",
"pageLayoutWidget",
"rolePermissionFlag",
"roleTarget",
"viewSort",
"index",
"pageLayout",
"roleTarget",
"rowLevelPermissionPredicateGroup",
"viewGroup",
"agent",
@@ -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<ApplicationEntity>,
@InjectWorkspaceScopedRepository(RoleEntity)
private readonly roleRepository: WorkspaceScopedRepository<RoleEntity>,
@InjectWorkspaceScopedRepository(AgentEntity)
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatRoleTargetMaps> {
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({
@@ -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<AgentEntity> | null;
@Column({ nullable: true, type: 'uuid' })
apiKeyId: string | null;
@@ -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,
});
@@ -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<AllFlatEntityMaps, 'flatRoleTargetMaps' | 'flatRoleMaps'>): {
} & 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(),
@@ -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),
@@ -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,
@@ -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,
@@ -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,
@@ -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`,
});
}
@@ -25,11 +25,13 @@ export class CreateRoleTargetActionHandlerService extends WorkspaceMigrationRunn
allFlatEntityMaps,
flatApplication,
workspaceId,
preallocatedIdByUniversalIdentifierByMetadataName,
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateRoleTargetAction>): Promise<FlatCreateRoleTargetAction> {
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,
@@ -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<Pick<Manifest, 'agents' | 'roles'>>,
) =>
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<string> => {
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);
});
@@ -9,4 +9,5 @@ export type AgentManifest = SyncableEntityOptions & {
prompt: string;
modelId?: string;
responseFormat?: AgentResponseFormat;
roleUniversalIdentifier?: string;
};
@@ -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,
});
@@ -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';