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:
Félix Malfait
2025-09-10 15:12:38 +02:00
committed by GitHub
parent 9a05daa624
commit 30a2164980
79 changed files with 5515 additions and 365 deletions
@@ -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;
}
@@ -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;
}
}
}
@@ -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}`);
}
}
}
}
@@ -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;
}
}
@@ -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[];
};