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:
@@ -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": "🎧"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+236
@@ -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 {}
|
||||
|
||||
+10
-13
@@ -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;
|
||||
}
|
||||
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-120
@@ -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;
|
||||
}
|
||||
}
|
||||
-10
@@ -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;
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+1
@@ -33,6 +33,7 @@ export const getFlatObjectMetadataMock = (
|
||||
nameSingular: 'defaultflatObjectMetadataNameSingular',
|
||||
shortcut: 'shortcut',
|
||||
standardId: null,
|
||||
applicationId: null,
|
||||
standardOverrides: null,
|
||||
targetTableName: '',
|
||||
workspaceId: faker.string.uuid(),
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+6
@@ -56,6 +56,12 @@ export class CreateObjectInput {
|
||||
@HideField()
|
||||
workspaceId: string;
|
||||
|
||||
@HideField()
|
||||
applicationId?: string;
|
||||
|
||||
@HideField()
|
||||
standardId?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
|
||||
+3
-1
@@ -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,
|
||||
},
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -115,6 +115,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa
|
||||
ObjectMetadataResolver,
|
||||
BeforeUpdateOneObject,
|
||||
],
|
||||
exports: [ObjectMetadataService],
|
||||
exports: [ObjectMetadataService, ObjectMetadataServiceV2],
|
||||
})
|
||||
export class ObjectMetadataModule {}
|
||||
|
||||
+5
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user