First Application POC (#14382)
Quick proof of concept for twenty-apps + twenty-cli, with local development / hot reload Let's discuss it! https://github.com/user-attachments/assets/c6789936-cd5f-4110-a265-863a6ac1af2d
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@Entity({ name: 'application', schema: 'core' })
|
||||
@Index('IDX_APPLICATION_WORKSPACE_ID', ['workspaceId'])
|
||||
@Index(
|
||||
'IDX_APPLICATION_STANDARD_ID_WORKSPACE_ID_UNIQUE',
|
||||
['standardId', 'workspaceId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL AND "standardId" IS NOT NULL',
|
||||
},
|
||||
)
|
||||
export class ApplicationEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
standardId?: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
label: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
version: string | null;
|
||||
|
||||
@Column({ type: 'text', default: 'local' })
|
||||
sourceType: 'local';
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
sourcePath: string;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => Workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, Workspace]),
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationService,
|
||||
ApplicationSyncService,
|
||||
ApplicationSyncAgentService,
|
||||
LocalApplicationSourceProvider,
|
||||
],
|
||||
exports: [ApplicationService, ApplicationSyncService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
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';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
@Resolver()
|
||||
export class ApplicationResolver {
|
||||
constructor(
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => ApplicationDTO)
|
||||
async syncApplication(
|
||||
@Args('manifest', { type: () => GraphQLJSON })
|
||||
manifest: ApplicationManifest,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
): Promise<ApplicationDTO> {
|
||||
const application =
|
||||
await this.applicationSyncService.synchronizeFromManifest(
|
||||
workspaceId,
|
||||
manifest,
|
||||
);
|
||||
|
||||
return application;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<ApplicationEntity | null> {
|
||||
return this.applicationRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async findByStandardId(
|
||||
standardId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationEntity[]> {
|
||||
return this.applicationRepository.find({
|
||||
where: {
|
||||
standardId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
standardId?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
sourcePath: string;
|
||||
workspaceId: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const application = this.applicationRepository.create({
|
||||
...data,
|
||||
sourceType: 'local',
|
||||
});
|
||||
|
||||
return this.applicationRepository.save(application);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: {
|
||||
label?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
sourcePath?: string;
|
||||
},
|
||||
): Promise<ApplicationEntity> {
|
||||
await this.applicationRepository.update({ id }, data);
|
||||
|
||||
const updatedApplication = await this.findById(id);
|
||||
|
||||
if (!updatedApplication) {
|
||||
throw new Error(`Failed to update application with id ${id}`);
|
||||
}
|
||||
|
||||
return updatedApplication;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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
@@ -0,0 +1,35 @@
|
||||
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
@@ -0,0 +1,72 @@
|
||||
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
@@ -0,0 +1,120 @@
|
||||
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
@@ -0,0 +1,10 @@
|
||||
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[];
|
||||
};
|
||||
Reference in New Issue
Block a user