1541 extensibility twenty cli use workspace migration v2 to synchronize application objects fields views (#14706)

- synchronize objects


https://github.com/user-attachments/assets/257317bc-2881-4b98-a3d4-6ae52bd72aa0
This commit is contained in:
martmull
2025-09-25 11:22:11 +02:00
committed by GitHub
parent 71216b4db8
commit edb331d68b
34 changed files with 596 additions and 683 deletions
@@ -4,7 +4,7 @@
"title": "Twenty Agent Manifest",
"description": "Schema for Twenty AI agent configuration files",
"type": "object",
"required": ["standardId", "name", "label", "prompt"],
"required": ["standardId", "name", "label", "prompt", "modelId"],
"properties": {
"$schema": {
"type": "string",
@@ -54,6 +54,15 @@
"description": "Inline agent definition",
"$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json"
}
},
"objects": {
"type": "array",
"description": "Optional inline object definitions (objects are typically discovered from the objects/ folder)",
"items": {
"type": "object",
"description": "Inline object definition",
"$ref": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/object.schema.json"
}
}
},
"examples": [
@@ -0,0 +1,73 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/object.schema.json",
"title": "Twenty Object Manifest",
"description": "Schema for Twenty AI object configuration files",
"type": "object",
"required": [
"standardId",
"nameSingular",
"namePlural",
"labelSingular",
"labelPlural"
],
"properties": {
"$schema": {
"type": "string",
"description": "JSON Schema reference for validation and IDE support"
},
"standardId": {
"type": "string",
"description": "Unique identifier for the object (UUID format recommended)",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"nameSingular": {
"type": "string",
"description": "Name singular for the object",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"namePlural": {
"type": "string",
"description": "Name plural for the object",
"pattern": "^[a-zA-Z][a-zA-Z0-9]*$",
"minLength": 1,
"maxLength": 100
},
"labelSingular": {
"type": "string",
"description": "Human-readable display name singular for the object",
"minLength": 1,
"maxLength": 200
},
"labelPlural": {
"type": "string",
"description": "Human-readable display name singular for the object",
"minLength": 1,
"maxLength": 200
},
"description": {
"type": "string",
"description": "Brief description of the object",
"maxLength": 500
},
"icon": {
"type": "string",
"description": "Icon for the object (emoji or icon name)",
"maxLength": 50
}
},
"additionalProperties": false,
"examples": [
{
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"nameSingular": "object",
"namePlural": "objects",
"labelSingular": "Object",
"labelPlural": "Objects",
"description": "Object description",
"icon": "🎧"
}
]
}
+1 -129
View File
@@ -83,15 +83,7 @@ export class ApiService {
try {
const mutation = `
mutation SyncApplication($manifest: JSON!) {
syncApplication(manifest: $manifest) {
id
standardId
label
description
version
createdAt
updatedAt
}
syncApplication(manifest: $manifest)
}
`;
@@ -137,124 +129,4 @@ export class ApiService {
throw error;
}
}
async installApplication(
source: string,
sourceType: 'git' | 'local' | 'marketplace' = 'local',
): Promise<ApiResponse> {
// For now, installation is the same as syncing a local manifest
// In the future, this could handle different source types
try {
if (sourceType === 'local') {
// Try to load manifest using the new loader
try {
const { loadAppManifest } = await import(
'../utils/app-manifest-loader'
);
const manifest = await loadAppManifest(source);
return this.syncApplication(manifest);
} catch (manifestError) {
return {
success: false,
error: `Failed to load manifest: ${manifestError instanceof Error ? manifestError.message : 'Unknown error'}`,
};
}
}
return {
success: false,
error: `Source type "${sourceType}" not yet supported`,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Installation failed',
};
}
}
async listApplications(): Promise<ApiResponse> {
try {
const query = `
query FindManyAgents {
findManyAgents {
id
name
label
description
isCustom
createdAt
updatedAt
}
}
`;
const response: AxiosResponse = await this.client.post('/metadata', {
query,
});
if (response.data.errors) {
return {
success: false,
error: response.data.errors[0]?.message || 'Failed to fetch agents',
};
}
return {
success: true,
data: response.data.data.findManyAgents,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
throw error;
}
}
async getWorkspaces(): Promise<ApiResponse> {
try {
const query = `
query CurrentUser {
currentUser {
id
email
currentWorkspace {
id
displayName
}
}
}
`;
const response: AxiosResponse = await this.client.post('/metadata', {
query,
});
if (response.data.errors) {
return {
success: false,
error:
response.data.errors[0]?.message || 'Failed to fetch workspace',
};
}
const workspace = response.data.data.currentUser?.currentWorkspace;
return {
success: true,
data: workspace ? [workspace] : [],
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
success: false,
error: error.response.data?.errors?.[0]?.message || error.message,
};
}
throw error;
}
}
}
+25 -6
View File
@@ -4,7 +4,8 @@ export interface TwentyConfig {
defaultApp?: string;
}
export interface AppManifest {
export type PackageJson = {
$schema?: string;
standardId: string;
label: string;
description?: string;
@@ -12,19 +13,37 @@ export interface AppManifest {
version: string;
dependencies?: object;
devDependencies?: object;
agents: AgentManifest[];
}
};
export interface AgentManifest {
export type AppManifest = PackageJson & {
agents: AgentManifest[];
objects: ObjectManifest[];
};
export type CoreEntityManifest = AgentManifest | ObjectManifest;
export type ObjectManifest = {
$schema?: string;
standardId: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
export type AgentManifest = {
$schema?: string;
standardId: string;
name: string;
label: string;
description?: string;
icon?: string;
prompt: string;
modelId?: string;
modelId: string;
responseFormat?: AgentResponseFormat;
}
};
export interface AgentResponseFormat {
type: 'json' | 'text';
@@ -1,95 +1,64 @@
import * as fs from 'fs-extra';
import * as path from 'path';
import { AgentManifest, AppManifest } from '../types/config.types';
import { AppManifest, CoreEntityManifest } from '../types/config.types';
import { parseJsoncFile } from './jsonc-parser';
import { schemaValidator } from './schema-validator';
import { validateSchema } from '../utils/schema-validator';
export interface AppManifestWithMeta extends AppManifest {
_meta?: {
agentFiles?: string[];
manifestPath?: string;
};
}
const findPackageJsonFile = async (appPath: string): Promise<string> => {
const jsonPath = path.join(appPath, 'package.json');
export type AppManifestRaw = Omit<AppManifest, 'agents'> & {
// agents will be discovered from the agents/ folder
agents?: AgentManifest[];
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(`package.json not found in ${appPath}`);
};
export class AppManifestLoader {
private appPath: string;
const loadCoreEntity = async (
coreEntityPath: string,
validator: (manifest: CoreEntityManifest, path: string) => Promise<void>,
): Promise<CoreEntityManifest[]> => {
const coreEntities: CoreEntityManifest[] = [];
constructor(appPath: string) {
this.appPath = appPath;
}
if (await fs.pathExists(coreEntityPath)) {
const files = await fs.readdir(coreEntityPath);
const coreEntityFileNames = files.filter(
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
);
async loadManifest(): Promise<AppManifestWithMeta> {
const packageJsonPath = await this.findPackageJsonFile();
const rawPackageJson = await parseJsoncFile(packageJsonPath);
// Validate the raw manifest structure
await schemaValidator.validateAppManifest(rawPackageJson, packageJsonPath);
return this.discoverAndLoadAgents(rawPackageJson, packageJsonPath);
}
private async findPackageJsonFile(): Promise<string> {
const jsonPath = path.join(this.appPath, 'package.json');
if (await fs.pathExists(jsonPath)) {
return jsonPath;
}
throw new Error(`package.json not found in ${this.appPath}`);
}
private async discoverAndLoadAgents(
rawManifest: AppManifestRaw,
manifestPath: string,
): Promise<AppManifestWithMeta> {
const agentsDir = path.join(this.appPath, 'agents');
const agentFiles: string[] = [];
const agents: AgentManifest[] = [];
// Check if agents directory exists
if (await fs.pathExists(agentsDir)) {
const files = await fs.readdir(agentsDir);
const agentFileNames = files.filter(
(file) => file.endsWith('.jsonc') || file.endsWith('.json'),
for (const fileName of coreEntityFileNames) {
const coreEntityManifest = await parseJsoncFile(
path.join(coreEntityPath, fileName),
);
for (const fileName of agentFileNames) {
const agentPath = path.join(agentsDir, fileName);
const agentManifest = await parseJsoncFile(agentPath);
await validator(coreEntityManifest, coreEntityPath);
// Validate the agent against schema
await schemaValidator.validateAgent(agentManifest, agentPath);
agents.push(agentManifest);
agentFiles.push(`agents/${fileName}`);
}
coreEntities.push(coreEntityManifest);
}
return {
...rawManifest,
agents,
_meta: {
agentFiles,
manifestPath,
},
};
}
}
// Convenience function for backward compatibility
export const loadAppManifest = async (
appPath: string,
): Promise<AppManifest> => {
const loader = new AppManifestLoader(appPath);
const manifest = await loader.loadManifest();
// Remove meta information for backward compatibility
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _meta, ...cleanManifest } = manifest;
return cleanManifest;
return coreEntities;
};
export const loadManifest = async (appPath: string): Promise<AppManifest> => {
const packageJsonPath = await findPackageJsonFile(appPath);
const rawPackageJson = await parseJsoncFile(packageJsonPath);
await validateSchema('app-manifest', rawPackageJson, packageJsonPath);
const agents = await loadCoreEntity(
path.join(appPath, 'agents'),
(manifest, path) => validateSchema('agent', manifest, path),
);
const objects = await loadCoreEntity(
path.join(appPath, 'objects'),
(manifest, path) => validateSchema('object', manifest, path),
);
return {
...rawPackageJson,
agents,
objects,
};
};
+2 -2
View File
@@ -1,12 +1,12 @@
import chalk from 'chalk';
import { ApiService } from '../services/api.service';
import { loadAppManifest } from './app-manifest-loader';
import { loadManifest } from './app-manifest-loader';
export const syncApp = async (
appPath: string,
apiService: ApiService,
): Promise<any> => {
const manifest = await loadAppManifest(appPath);
const manifest = await loadManifest(appPath);
try {
const result = await apiService.syncApplication(manifest);
+6 -15
View File
@@ -1,18 +1,9 @@
import { randomUUID } from 'crypto';
import { AgentManifest, AppManifest } from '../types/config.types';
import { SchemaValidator } from './schema-validator';
import { AgentManifest, PackageJson } from '../types/config.types';
import { getSchemaUrls } from './schema-validator';
export type AppManifestTemplate = Omit<AppManifest, 'agents'> & {
$schema?: string;
// agents will be discovered from the agents/ folder
};
export type AgentManifestTemplate = AgentManifest & {
$schema?: string;
};
export const createBasePackageJson = (appName: string): AppManifestTemplate => {
const schemas = SchemaValidator.getSchemaUrls();
export const createBasePackageJson = (appName: string): PackageJson => {
const schemas = getSchemaUrls();
return {
$schema: schemas.appManifest,
@@ -26,8 +17,8 @@ export const createBasePackageJson = (appName: string): AppManifestTemplate => {
};
};
export const createAgentManifest = (appName: string): AgentManifestTemplate => {
const schemas = SchemaValidator.getSchemaUrls();
export const createAgentManifest = (appName: string): AgentManifest => {
const schemas = getSchemaUrls();
return {
$schema: schemas.agent,
@@ -1,5 +1,4 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as fs from 'fs-extra';
import * as path from 'path';
@@ -14,107 +13,64 @@ export class SchemaValidationError extends Error {
}
}
export class SchemaValidator {
private ajv: Ajv;
private schemasLoaded = false;
const formatErrors = (errors: any[]): string => {
return errors
.map((error) => {
const path = error.instancePath || 'root';
const message = error.message;
const value =
error.data !== undefined ? ` (got: ${JSON.stringify(error.data)})` : '';
return `${path}: ${message}${value}`;
})
.join('\n');
};
constructor() {
this.ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
});
addFormats(this.ajv);
}
export const validateSchema = async (
schemaName: 'app-manifest' | 'agent' | 'object',
manifest: any,
filePath?: string,
): Promise<void> => {
const ajv = new Ajv({
allErrors: true,
verbose: true,
strict: false,
});
private async loadSchemas(): Promise<void> {
if (this.schemasLoaded) return;
const schemaUrls = getSchemaUrls();
let schema;
for (const name of Object.keys(schemaUrls) as (keyof typeof schemaUrls)[]) {
const formattedName = name === 'appManifest' ? 'app-manifest' : name;
const schemasDir = path.join(__dirname, '../../schemas');
const schemaPath = path.join(schemasDir, `${formattedName}.schema.json`);
ajv.addSchema(await fs.readJson(schemaPath));
try {
// Load agent schema
const agentSchemaPath = path.join(schemasDir, 'agent.schema.json');
const agentSchema = await fs.readJson(agentSchemaPath);
this.ajv.addSchema(agentSchema, 'agent');
// Load app manifest schema
const appSchemaPath = path.join(schemasDir, 'app-manifest.schema.json');
const appSchema = await fs.readJson(appSchemaPath);
this.ajv.addSchema(appSchema, 'app-manifest');
this.schemasLoaded = true;
} catch {
// Gracefully handle missing schemas in development
console.warn('Warning: Could not load JSON schemas for validation');
this.schemasLoaded = true; // Prevent retry
if (formattedName === schemaName) {
schema = ajv.getSchema(schemaUrls[name])?.schema;
}
}
async validateAgent(agent: any, filePath?: string): Promise<void> {
await this.loadSchemas();
if (!schema) throw new Error(`Schema ${schemaName} not found.`);
const validate = this.ajv.getSchema('agent');
if (!validate) {
// Schema not available, skip validation
return;
}
const valid = ajv.validate(schema, manifest);
const valid = validate(agent);
if (!valid) {
const errorMessages = this.formatErrors(validate.errors || []);
throw new SchemaValidationError(
`Agent validation failed:\n${errorMessages}`,
validate.errors || [],
filePath,
);
}
if (!valid) {
const errorMessages = formatErrors(ajv.errors || []);
throw new SchemaValidationError(
`${schemaName} validation failed:\n${errorMessages}`,
ajv.errors || [],
filePath,
);
}
};
async validateAppManifest(manifest: any, filePath?: string): Promise<void> {
await this.loadSchemas();
const validate = this.ajv.getSchema('app-manifest');
if (!validate) {
// Schema not available, skip validation
return;
}
const valid = validate(manifest);
if (!valid) {
const errorMessages = this.formatErrors(validate.errors || []);
throw new SchemaValidationError(
`App manifest validation failed:\n${errorMessages}`,
validate.errors || [],
filePath,
);
}
}
private formatErrors(errors: any[]): string {
return errors
.map((error) => {
const path = error.instancePath || 'root';
const message = error.message;
const value =
error.data !== undefined
? ` (got: ${JSON.stringify(error.data)})`
: '';
return `${path}: ${message}${value}`;
})
.join('\n');
}
// Get schema URLs for $schema references
static getSchemaUrls() {
return {
agent:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
appManifest:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
};
}
}
// Singleton instance
export const schemaValidator = new SchemaValidator();
export const getSchemaUrls = () => {
return {
agent:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json',
object:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/object.schema.json',
appManifest:
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json',
};
};
@@ -171,20 +171,6 @@ export type AppTokenEdge = {
node: AppToken;
};
export type Application = {
__typename?: 'Application';
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
sourcePath: Scalars['String'];
sourceType: Scalars['String'];
standardId?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
version?: Maybe<Scalars['String']>;
workspaceId: Scalars['UUID'];
};
export type ApprovedAccessDomain = {
__typename?: 'ApprovedAccessDomain';
createdAt: Scalars['DateTime'];
@@ -1599,7 +1585,7 @@ export type Mutation = {
submitFormStep: Scalars['Boolean'];
switchBillingPlan: BillingUpdateOutput;
switchSubscriptionInterval: BillingUpdateOutput;
syncApplication: Application;
syncApplication: Scalars['Boolean'];
syncRemoteTable: RemoteTable;
syncRemoteTableSchemaChanges: RemoteTable;
trackAnalytics: Analytics;
+1 -15
View File
@@ -171,20 +171,6 @@ export type AppTokenEdge = {
node: AppToken;
};
export type Application = {
__typename?: 'Application';
createdAt: Scalars['DateTime'];
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
sourcePath: Scalars['String'];
sourceType: Scalars['String'];
standardId?: Maybe<Scalars['UUID']>;
updatedAt: Scalars['DateTime'];
version?: Maybe<Scalars['String']>;
workspaceId: Scalars['UUID'];
};
export type ApprovedAccessDomain = {
__typename?: 'ApprovedAccessDomain';
createdAt: Scalars['DateTime'];
@@ -1554,7 +1540,7 @@ export type Mutation = {
submitFormStep: Scalars['Boolean'];
switchBillingPlan: BillingUpdateOutput;
switchSubscriptionInterval: BillingUpdateOutput;
syncApplication: Application;
syncApplication: Scalars['Boolean'];
trackAnalytics: Analytics;
updateApiKey?: Maybe<ApiKey>;
updateCoreView: CoreView;
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddApplicationIdToObjectMetadata1758720905726
implements MigrationInterface
{
name = 'AddApplicationIdToObjectMetadata1758720905726';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."objectMetadata" ADD "applicationId" uuid`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."objectMetadata" DROP COLUMN "applicationId"`,
);
}
}
@@ -0,0 +1,236 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
AgentManifest,
AppManifest,
ObjectManifest,
} from 'src/engine/core-modules/application/types/application.types';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.service.';
import type { FlatObjectMetadataWithFlatFieldMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-with-flat-field-metadata-maps.type';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
@Injectable()
export class ApplicationSyncService {
private readonly logger = new Logger(ApplicationSyncService.name);
constructor(
private readonly applicationService: ApplicationService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly dataSourceService: DataSourceService,
private readonly agentService: AgentService,
) {}
public async synchronizeFromManifest(
workspaceId: string,
manifest: AppManifest,
) {
const applicationId = await this.syncApplication(manifest, workspaceId);
await this.syncAgents({
agentsToSync: manifest.agents,
workspaceId,
applicationId,
});
await this.syncObjects({
objectsToSync: manifest.objects,
workspaceId,
applicationId,
});
this.logger.log('✅ Application sync from manifest completed');
}
private async syncApplication(
applicationToSync: AppManifest,
workspaceId: string,
): Promise<string> {
const application = await this.applicationService.findByStandardId(
applicationToSync.standardId,
workspaceId,
);
if (!isDefined(application)) {
const createdApplication = await this.applicationService.create({
standardId: applicationToSync.standardId,
label: applicationToSync.label,
description: applicationToSync.description,
version: applicationToSync.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
workspaceId,
});
return createdApplication.id;
}
await this.applicationService.update(application.id, {
label: applicationToSync.label,
description: applicationToSync.description,
version: applicationToSync.version,
});
return application.id;
}
private async syncAgents({
agentsToSync,
workspaceId,
applicationId,
}: {
agentsToSync: AgentManifest[];
workspaceId: string;
applicationId: string;
}) {
for (const agentToSync of agentsToSync) {
const existingAgent =
await this.agentService.findOneByApplicationAndStandardId({
workspaceId,
applicationId,
standardId: agentToSync.standardId,
});
if (isDefined(existingAgent)) {
await this.agentService.updateOneAgent(
{ id: existingAgent.id, ...agentToSync },
workspaceId,
);
return;
}
await this.agentService.createOneAgent(
{
name: agentToSync.name,
label: agentToSync.label,
description: agentToSync.description,
icon: agentToSync.icon,
prompt: agentToSync.prompt,
modelId: agentToSync.modelId,
standardId: agentToSync.standardId,
isCustom: true,
applicationId,
},
workspaceId,
);
}
}
private async syncObjects({
objectsToSync,
workspaceId,
applicationId,
}: {
objectsToSync: ObjectManifest[];
workspaceId: string;
applicationId: string;
}) {
const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatEntities: ['flatObjectMetadataMaps'],
},
);
const applicationObjects = Object.values(
existingFlatObjectMetadataMaps.byId,
).filter(
(obj) => isDefined(obj) && obj.applicationId === applicationId,
) as FlatObjectMetadataWithFlatFieldMaps[];
const objectsToSyncStandardIds = objectsToSync.map((obj) => obj.standardId);
const applicationObjectsStandardIds = applicationObjects.map(
(obj) => obj.standardId,
);
const objectsToDelete = applicationObjects.filter(
(obj) =>
isDefined(obj.standardId) &&
!objectsToSyncStandardIds.includes(obj.standardId),
);
const objectsToUpdate = applicationObjects.filter(
(obj) =>
isDefined(obj.standardId) &&
objectsToSyncStandardIds.includes(obj.standardId),
);
const objectsToCreate = objectsToSync.filter(
(objectToSync) =>
!applicationObjectsStandardIds.includes(objectToSync.standardId),
);
for (const objectToDelete of objectsToDelete) {
await this.objectMetadataServiceV2.deleteOne({
deleteObjectInput: { id: objectToDelete.id },
workspaceId,
isSystemBuild: true,
});
}
for (const objectToUpdate of objectsToUpdate) {
const objectToSync = objectsToSync.find(
(obj) => obj.standardId === objectToUpdate.standardId,
);
if (!objectToSync) {
throw new ApplicationException(
`Failed to find object to sync with standardId ${objectToUpdate.standardId}`,
ApplicationExceptionCode.OBJECT_NOT_FOUND,
);
}
const updateObjectInput = {
id: objectToUpdate.id,
update: {
nameSingular: objectToSync.nameSingular,
namePlural: objectToSync.namePlural,
labelSingular: objectToSync.labelSingular,
labelPlural: objectToSync.labelPlural,
icon: objectToSync.icon || undefined,
description: objectToSync.description || undefined,
},
};
await this.objectMetadataServiceV2.updateOne({
updateObjectInput,
workspaceId,
});
}
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
for (const objectToCreate of objectsToCreate) {
const createObjectInput = {
nameSingular: objectToCreate.nameSingular,
namePlural: objectToCreate.namePlural,
labelSingular: objectToCreate.labelSingular,
labelPlural: objectToCreate.labelPlural,
icon: objectToCreate.icon || undefined,
description: objectToCreate.description || undefined,
standardId: objectToCreate.standardId || undefined,
dataSourceId: dataSourceMetadata.id,
applicationId,
};
await this.objectMetadataServiceV2.createOne({
createObjectInput,
workspaceId,
});
}
}
}
@@ -0,0 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApplicationException extends CustomException<ApplicationExceptionCode> {}
export enum ApplicationExceptionCode {
OBJECT_NOT_FOUND = 'OBJECT_NOT_FOUND',
}
@@ -4,23 +4,22 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationResolver } from 'src/engine/core-modules/application/application.resolver';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { LocalApplicationSourceProvider } from 'src/engine/core-modules/application/providers/local-application-source.provider';
import { ApplicationSyncAgentService } from 'src/engine/core-modules/application/services/application-sync-agent.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/core-modules/common/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, Workspace]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ObjectMetadataModule,
DataSourceModule,
AgentModule,
],
providers: [
ApplicationResolver,
ApplicationService,
ApplicationSyncService,
ApplicationSyncAgentService,
LocalApplicationSourceProvider,
],
exports: [ApplicationService, ApplicationSyncService],
providers: [ApplicationResolver, ApplicationService, ApplicationSyncService],
})
export class ApplicationModule {}
@@ -3,13 +3,11 @@ import { Args, Mutation, Resolver } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { AppManifest } from 'src/engine/core-modules/application/types/application.types';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ApplicationDTO } from './dtos/application.dto';
import { ApplicationSyncService } from './services/application-sync.service';
import { ApplicationManifest } from './types/application-manifest.type';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-sync.service';
@UseGuards(WorkspaceAuthGuard)
@Resolver()
@@ -18,18 +16,17 @@ export class ApplicationResolver {
private readonly applicationSyncService: ApplicationSyncService,
) {}
@Mutation(() => ApplicationDTO)
@Mutation(() => Boolean)
async syncApplication(
@Args('manifest', { type: () => GraphQLJSON })
manifest: ApplicationManifest,
manifest: AppManifest,
@AuthWorkspace() { id: workspaceId }: Workspace,
): Promise<ApplicationDTO> {
const application =
await this.applicationSyncService.synchronizeFromManifest(
workspaceId,
manifest,
);
) {
await this.applicationSyncService.synchronizeFromManifest(
workspaceId,
manifest,
);
return application;
return true;
}
}
@@ -18,11 +18,8 @@ export class ApplicationService {
});
}
async findByStandardId(
standardId: string,
workspaceId: string,
): Promise<ApplicationEntity[]> {
return this.applicationRepository.find({
async findByStandardId(standardId: string, workspaceId: string) {
return this.applicationRepository.findOne({
where: {
standardId,
workspaceId,
@@ -1,58 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('Application')
export class ApplicationDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
standardId?: string;
@IsString()
@Field(() => String)
label: string;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
description?: string | null;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
version?: string | null;
@IsString()
@Field(() => String)
sourceType: string;
@IsString()
@Field(() => String)
sourcePath: string;
@IsUUID()
@Field(() => UUIDScalarType)
workspaceId: string;
@IsDateString()
@Field(() => Date)
createdAt: Date;
@IsDateString()
@Field(() => Date)
updatedAt: Date;
}
@@ -1,35 +0,0 @@
import { Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import * as path from 'path';
import { ApplicationManifest } from 'src/engine/core-modules/application/types/application-manifest.type';
@Injectable()
export class LocalApplicationSourceProvider {
async fetchManifest(localPath: string): Promise<ApplicationManifest> {
const manifestPath = path.join(localPath, 'twenty-app.json');
try {
const manifestContent = await fs.readFile(manifestPath, 'utf-8');
return JSON.parse(manifestContent) as ApplicationManifest;
} catch (error) {
throw new Error(
`Failed to read manifest from ${manifestPath}: ${error.message}`,
);
}
}
async validateSource(localPath: string): Promise<boolean> {
const manifestPath = path.join(localPath, 'twenty-app.json');
try {
await fs.access(manifestPath);
return true;
} catch {
return false;
}
}
}
@@ -1,72 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationSyncContext } from 'src/engine/core-modules/application/services/application-sync.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
@Injectable()
export class ApplicationSyncAgentService {
private readonly logger = new Logger(ApplicationSyncAgentService.name);
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
) {}
async synchronize(
context: ApplicationSyncContext,
agents: FlatAgent[],
): Promise<void> {
if (!agents || agents.length === 0) {
this.logger.log('No agents to synchronize');
return;
}
for (const agentDefinition of agents) {
this.logger.log(`Syncing agent: ${agentDefinition.label}`);
// Check if agent already exists
const existingAgent = await this.agentRepository.findOne({
where: {
workspaceId: context.workspaceId,
applicationId: context.applicationId,
name: agentDefinition.name,
},
});
if (existingAgent) {
// Update existing agent
await this.agentRepository.update(
{ id: existingAgent.id },
{
label: agentDefinition.label,
description: agentDefinition.description,
icon: agentDefinition.icon,
prompt: agentDefinition.prompt,
modelId: agentDefinition.modelId,
},
);
this.logger.log(`Updated agent: ${agentDefinition.label}`);
} else {
// Create new agent
const newAgent = this.agentRepository.create({
name: agentDefinition.name,
label: agentDefinition.label,
description: agentDefinition.description,
icon: agentDefinition.icon,
prompt: agentDefinition.prompt,
modelId: agentDefinition.modelId,
workspaceId: context.workspaceId,
applicationId: context.applicationId,
});
await this.agentRepository.save(newAgent);
this.logger.log(`Created agent: ${agentDefinition.label}`);
}
}
}
}
@@ -1,120 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { LocalApplicationSourceProvider } from 'src/engine/core-modules/application/providers/local-application-source.provider';
import { ApplicationSyncAgentService } from 'src/engine/core-modules/application/services/application-sync-agent.service';
import { FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
export interface ApplicationSyncContext {
workspaceId: string;
featureFlags: Record<string, boolean>;
applicationId: string;
}
interface ApplicationManifest {
standardId: string;
label: string;
description?: string;
version?: string;
agents?: FlatAgent[];
}
@Injectable()
export class ApplicationSyncService {
private readonly logger = new Logger(ApplicationSyncService.name);
constructor(
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly localSourceProvider: LocalApplicationSourceProvider,
private readonly applicationSyncAgentService: ApplicationSyncAgentService,
private readonly applicationService: ApplicationService,
) {}
public async synchronize(context: ApplicationSyncContext): Promise<void> {
this.logger.log(`Syncing agents for application: ${context.applicationId}`);
const application = await this.applicationRepository.findOne({
where: { id: context.applicationId },
});
if (!application) {
throw new Error(`Application with ID ${context.applicationId} not found`);
}
const manifest = await this.localSourceProvider.fetchManifest(
application.sourcePath,
);
this.logger.log(`Syncing application: ${manifest.label}`);
await this.applicationSyncAgentService.synchronize(
context,
manifest.agents,
);
this.logger.log('✅ Agent sync completed');
}
public async synchronizeFromManifest(
workspaceId: string,
manifest: ApplicationManifest,
): Promise<ApplicationEntity> {
this.logger.log(`Syncing application from manifest: ${manifest.label}`);
// Find or create application
let application = await this.applicationService.findByStandardId(
manifest.standardId,
workspaceId,
);
if (application.length === 0) {
// Create new application
application = [
await this.applicationService.create({
standardId: manifest.standardId,
label: manifest.label,
description: manifest.description,
version: manifest.version,
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
workspaceId,
}),
];
this.logger.log(`Created new application: ${manifest.label}`);
} else {
// Update existing application
const existingApp = application[0];
await this.applicationService.update(existingApp.id, {
label: manifest.label,
description: manifest.description,
version: manifest.version,
});
this.logger.log(`Updated existing application: ${manifest.label}`);
}
const app = application[0];
// Sync agents
if (manifest.agents && manifest.agents.length > 0) {
const context: ApplicationSyncContext = {
workspaceId,
featureFlags: {}, // TODO: Get actual feature flags
applicationId: app.id,
};
await this.applicationSyncAgentService.synchronize(
context,
manifest.agents,
);
}
this.logger.log('✅ Application sync from manifest completed');
return app;
}
}
@@ -1,10 +0,0 @@
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
export type ApplicationManifest = {
standardId: string;
label: string;
description?: string;
icon?: string;
version: string;
agents: FlatAgent[];
};
@@ -0,0 +1,43 @@
export type PackageJson = {
$schema?: string;
standardId: string;
label: string;
description?: string;
icon?: string;
version: string;
dependencies?: object;
devDependencies?: object;
};
export type AppManifest = PackageJson & {
agents: AgentManifest[];
objects: ObjectManifest[];
};
export type ObjectManifest = {
$schema?: string;
standardId: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
};
interface AgentResponseFormat {
type: 'json' | 'text';
schema?: Record<string, unknown>;
}
export type AgentManifest = {
$schema?: string;
standardId: string;
name: string;
label: string;
description?: string;
icon?: string;
prompt: string;
modelId: string;
responseFormat?: AgentResponseFormat;
};
@@ -54,6 +54,20 @@ export class AgentService {
}));
}
async findOneByApplicationAndStandardId({
applicationId,
standardId,
workspaceId,
}: {
applicationId: string;
standardId: string;
workspaceId: string;
}) {
return await this.agentRepository.findOne({
where: { applicationId, standardId, workspaceId },
});
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
@@ -1,4 +1,4 @@
import { Field, InputType } from '@nestjs/graphql';
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsNotEmpty,
@@ -53,4 +53,10 @@ export class CreateAgentInput {
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
responseFormat?: object;
@HideField()
standardId?: string;
@HideField()
applicationId?: string;
}
@@ -99,6 +99,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"description": "new field description",
"flatRelationTargetFieldMetadata": null,
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": null,
"duplicateCriteria": null,
@@ -154,6 +155,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -211,6 +213,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": null,
"duplicateCriteria": null,
@@ -275,6 +278,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"description": "new field description",
"flatRelationTargetFieldMetadata": null,
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": null,
"duplicateCriteria": null,
@@ -330,6 +334,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -400,6 +405,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"description": "new field description",
"flatRelationTargetFieldMetadata": null,
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A company",
"duplicateCriteria": null,
@@ -455,6 +461,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -512,6 +519,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A company",
"duplicateCriteria": null,
@@ -576,6 +584,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"description": "new field description",
"flatRelationTargetFieldMetadata": null,
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A company",
"duplicateCriteria": null,
@@ -631,6 +640,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -33,6 +33,7 @@ export const getFlatObjectMetadataMock = (
nameSingular: 'defaultflatObjectMetadataNameSingular',
shortcut: 'shortcut',
standardId: null,
applicationId: null,
standardOverrides: null,
targetTableName: '',
workspaceId: faker.string.uuid(),
@@ -76,8 +76,9 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
namePlural: createObjectInput.namePlural,
nameSingular: createObjectInput.nameSingular,
shortcut: createObjectInput.shortcut ?? null,
standardId: null,
standardId: createObjectInput.standardId ?? null,
standardOverrides: null,
applicationId: createObjectInput.applicationId ?? null,
universalIdentifier: objectMetadataId,
targetTableName: 'DEPRECATED',
workspaceId,
@@ -56,6 +56,12 @@ export class CreateObjectInput {
@HideField()
workspaceId: string;
@HideField()
applicationId?: string;
@HideField()
standardId?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
@@ -171,9 +171,11 @@ export class ObjectMetadataServiceV2 {
async deleteOne({
deleteObjectInput,
workspaceId,
isSystemBuild = false,
}: {
deleteObjectInput: DeleteOneObjectInput;
workspaceId: string;
isSystemBuild?: boolean;
}): Promise<ObjectMetadataDTO> {
const {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
@@ -253,7 +255,7 @@ export class ObjectMetadataServiceV2 {
},
buildOptions: {
inferDeletionFromMissingEntities: true,
isSystemBuild: false,
isSystemBuild,
},
workspaceId,
},
@@ -34,6 +34,9 @@ export class ObjectMetadataEntity implements Required<ObjectMetadataEntity> {
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: true, type: 'uuid' })
applicationId: string | null;
@Column({ nullable: false, type: 'uuid' })
dataSourceId: string;
@@ -115,6 +115,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
ObjectMetadataResolver,
BeforeUpdateOneObject,
],
exports: [ObjectMetadataService],
exports: [ObjectMetadataService, ObjectMetadataServiceV2],
})
export class ObjectMetadataModule {}
@@ -129,6 +129,7 @@ exports[`Workspace migration builder field actions test suite It should build an
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -196,6 +197,7 @@ exports[`Workspace migration builder field actions test suite It should build an
"defaultValue": null,
"description": "default flat field metadata description",
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -252,6 +254,7 @@ exports[`Workspace migration builder field actions test suite It should build an
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"applicationId": null,
"createdAt": Any<String>,
"description": null,
"duplicateCriteria": null,
@@ -1054,6 +1057,7 @@ exports[`Workspace migration builder object actions test suite It should build a
},
],
"flatObjectMetadataWithoutFields": {
"applicationId": null,
"createdAt": Any<String>,
"description": "A rocket",
"duplicateCriteria": null,
@@ -2373,6 +2377,7 @@ exports[`Workspace migration builder object actions test suite It should build a
},
],
"flatObjectMetadataWithoutFields": {
"applicationId": null,
"createdAt": Any<String>,
"description": null,
"duplicateCriteria": null,
@@ -41,6 +41,7 @@ export const getMockObjectMetadataEntity = (
objectPermissions: [],
shortcut: null,
standardId: null,
applicationId: null,
targetRelationFields: [],
standardOverrides: null,
targetTableName: faker.string.uuid(),