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:
@@ -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 });
|
||||
};
|
||||
|
||||
+22
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
+29
@@ -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,
|
||||
};
|
||||
};
|
||||
+14
@@ -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 ?? []) {
|
||||
|
||||
+15
-2
@@ -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',
|
||||
|
||||
+1
@@ -77,6 +77,7 @@ export const fromCreateAgentInputToFlatAgent = ({
|
||||
roleUniversalIdentifier,
|
||||
userWorkspaceId: null,
|
||||
agentId,
|
||||
agentUniversalIdentifier: flatAgentToCreate.universalIdentifier,
|
||||
apiKeyId: null,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+29
-17
@@ -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;
|
||||
|
||||
+1
-1
@@ -282,7 +282,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"roleUniversalIdentifier",
|
||||
"userWorkspaceId",
|
||||
"apiKeyId",
|
||||
"agentId",
|
||||
"agentUniversalIdentifier",
|
||||
],
|
||||
"propertiesToStringify": [],
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+1
@@ -120,6 +120,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
|
||||
exports[`getMetadataRelatedMetadataNames should return related metadata names for roleTarget 1`] = `
|
||||
[
|
||||
"role",
|
||||
"agent",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
+1
-1
@@ -12,10 +12,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
|
||||
"objectPermission",
|
||||
"pageLayoutWidget",
|
||||
"rolePermissionFlag",
|
||||
"roleTarget",
|
||||
"viewSort",
|
||||
"index",
|
||||
"pageLayout",
|
||||
"roleTarget",
|
||||
"rowLevelPermissionPredicateGroup",
|
||||
"viewGroup",
|
||||
"agent",
|
||||
|
||||
+11
-1
@@ -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;
|
||||
|
||||
|
||||
+8
-1
@@ -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,
|
||||
});
|
||||
|
||||
+15
-1
@@ -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),
|
||||
|
||||
+4
-4
@@ -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,
|
||||
|
||||
+10
-7
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+226
@@ -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;
|
||||
};
|
||||
|
||||
+14
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user