From 67c508a2e027f7e335f3dc3739b147d40a8495a8 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Wed, 29 Apr 2026 17:38:01 +0200 Subject: [PATCH 1/3] feat: workspace environment variable manipulation --- apps/backend-agent-manager/src/main.ts | 44 +++++- ...ateWorkspaceConfigurationOverridesTable.ts | 28 ++++ .../src/typeorm.config.ts | 7 + .../src/i18n/messages.de.xlf | 24 +++ .../src/i18n/messages.xlf | 18 +++ ...ence-workspace-configuration-overrides.mmd | 22 +++ .../spec/openapi.yaml | 121 +++++++++++++++ ...configuration-overrides.controller.spec.ts | 48 ++++++ ...ents-configuration-overrides.controller.ts | 55 +++++++ .../src/lib/modules/clients.module.ts | 5 + ...figuration-overrides-proxy.service.spec.ts | 55 +++++++ ...e-configuration-overrides-proxy.service.ts | 124 +++++++++++++++ .../feature-agent-manager/spec/openapi.yaml | 86 +++++++++++ .../feature-agent-manager/src/index.ts | 5 + .../workspace-configuration-settings.ts | 25 +++ ...configuration-overrides.controller.spec.ts | 55 +++++++ ...pace-configuration-overrides.controller.ts | 29 ++++ ...rt-workspace-configuration-override.dto.ts | 7 + ...pace-configuration-setting-response.dto.ts | 11 ++ ...workspace-configuration-override.entity.ts | 27 ++++ .../src/lib/modules/agents.module.spec.ts | 3 + .../src/lib/modules/agents.module.ts | 8 + ...pace-configuration-overrides.repository.ts | 45 ++++++ ...ce-configuration-overrides.service.spec.ts | 105 +++++++++++++ ...rkspace-configuration-overrides.service.ts | 93 +++++++++++ .../data-access-agent-console/src/index.ts | 7 + .../services/workspace-config.service.spec.ts | 38 +++++ .../lib/services/workspace-config.service.ts | 44 ++++++ .../knowledge-board-socket.effects.spec.ts | 1 + .../workspace-config.actions.ts | 61 ++++++++ .../workspace-config.effects.spec.ts | 52 +++++++ .../workspace-config.effects.ts | 94 +++++++++++ .../workspace-config.facade.spec.ts | 45 ++++++ .../workspace-config.facade.ts | 69 +++++++++ .../workspace-config.reducer.spec.ts | 52 +++++++ .../workspace-config.reducer.ts | 123 +++++++++++++++ .../workspace-config.selectors.ts | 44 ++++++ .../workspace-config.types.ts | 22 +++ .../src/lib/agent-console.routes.ts | 12 ++ .../src/lib/chat/chat.component.html | 146 ++++++++++++++++++ .../src/lib/chat/chat.component.ts | 139 ++++++++++++++++- 41 files changed, 1995 insertions(+), 4 deletions(-) create mode 100644 apps/backend-agent-manager/src/migrations/1773000000000_CreateWorkspaceConfigurationOverridesTable.ts create mode 100644 libs/domains/framework/backend/feature-agent-controller/docs/sequence-workspace-configuration-overrides.mmd create mode 100644 libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.spec.ts create mode 100644 libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts create mode 100644 libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.spec.ts create mode 100644 libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.spec.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/dto/workspace-configuration-setting-response.dto.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.spec.ts create mode 100644 libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.spec.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.actions.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.spec.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.spec.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.spec.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.selectors.ts create mode 100644 libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts diff --git a/apps/backend-agent-manager/src/main.ts b/apps/backend-agent-manager/src/main.ts index 595138fe..fbd4e717 100644 --- a/apps/backend-agent-manager/src/main.ts +++ b/apps/backend-agent-manager/src/main.ts @@ -3,6 +3,11 @@ * This is only a minimal backend to get started. */ +import { + WORKSPACE_CONFIGURATION_ENV_BY_SETTING, + WorkspaceConfigurationOverrideEntity, + type WorkspaceConfigurationSettingKey, +} from '@forepath/framework/backend/feature-agent-manager'; import { assertProductionEncryptionKeyOrExit } from '@forepath/shared/backend'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @@ -10,10 +15,47 @@ import { IoAdapter } from '@nestjs/platform-socket.io'; import { DataSource } from 'typeorm'; import { AppModule } from './app/app.module'; -import { typeormConfig } from './typeorm.config'; +import { typeormConfig, typeormConfigForConfigurationOverrides } from './typeorm.config'; + +async function preloadWorkspaceConfigurationOverrides(logger: Logger): Promise { + const preloadDataSource = new DataSource(typeormConfigForConfigurationOverrides); + + try { + await preloadDataSource.initialize(); + if (!typeormConfig.synchronize && typeormConfig.migrations?.length) { + logger.log('🔄 Running pending config migrations...'); + await preloadDataSource.runMigrations(); + logger.log('✅ Config migrations completed successfully'); + } else if (typeormConfig.synchronize) { + logger.log('ℹ️ Schema synchronization enabled - config migrations skipped'); + } + + logger.log('🔄 Loading config overrides...'); + + const overrides = await preloadDataSource.getRepository(WorkspaceConfigurationOverrideEntity).find(); + + for (const override of overrides) { + const settingKey = override.settingKey as WorkspaceConfigurationSettingKey; + const envVarName = WORKSPACE_CONFIGURATION_ENV_BY_SETTING[settingKey]; + + if (!envVarName) { + continue; + } + + process.env[envVarName] = override.value; + } + + logger.log(`✅ Loaded ${overrides.length} config override(s)`); + } finally { + if (preloadDataSource.isInitialized) { + await preloadDataSource.destroy(); + } + } +} async function bootstrap() { assertProductionEncryptionKeyOrExit(new Logger('EncryptionKey')); + await preloadWorkspaceConfigurationOverrides(new Logger('WorkspaceConfigurationOverridesBootstrap')); const app = await NestFactory.create(AppModule); // Configure CORS diff --git a/apps/backend-agent-manager/src/migrations/1773000000000_CreateWorkspaceConfigurationOverridesTable.ts b/apps/backend-agent-manager/src/migrations/1773000000000_CreateWorkspaceConfigurationOverridesTable.ts new file mode 100644 index 00000000..0e974dd6 --- /dev/null +++ b/apps/backend-agent-manager/src/migrations/1773000000000_CreateWorkspaceConfigurationOverridesTable.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateWorkspaceConfigurationOverridesTable1773000000000 implements MigrationInterface { + name = 'CreateWorkspaceConfigurationOverridesTable1773000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "workspace_configuration_overrides" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "setting_key" varchar(64) NOT NULL, + "value" text NOT NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_workspace_configuration_overrides_id" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_workspace_configuration_overrides_setting_key_unique" + ON "workspace_configuration_overrides" ("setting_key") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_workspace_configuration_overrides_setting_key_unique"`); + await queryRunner.query(`DROP TABLE IF EXISTS "workspace_configuration_overrides"`); + } +} diff --git a/apps/backend-agent-manager/src/typeorm.config.ts b/apps/backend-agent-manager/src/typeorm.config.ts index 41eab936..ea947d78 100644 --- a/apps/backend-agent-manager/src/typeorm.config.ts +++ b/apps/backend-agent-manager/src/typeorm.config.ts @@ -6,6 +6,7 @@ import { DeploymentConfigurationEntity, DeploymentRunEntity, RegexFilterRuleEntity, + WorkspaceConfigurationOverrideEntity, } from '@forepath/framework/backend'; import { DataSource, DataSourceOptions } from 'typeorm'; @@ -32,6 +33,7 @@ export const typeormConfig: DataSourceOptions = { DeploymentConfigurationEntity, DeploymentRunEntity, RegexFilterRuleEntity, + WorkspaceConfigurationOverrideEntity, ], // Migration paths: // - In development with TypeScript: use path from workspace root @@ -47,6 +49,11 @@ export const typeormConfig: DataSourceOptions = { logging: process.env.NODE_ENV === 'development', }; +export const typeormConfigForConfigurationOverrides: DataSourceOptions = { + ...typeormConfig, + entities: [WorkspaceConfigurationOverrideEntity], +}; + /** * TypeORM DataSource configuration for CLI operations. * This file is used by TypeORM CLI for generating and running migrations. diff --git a/apps/frontend-agent-console/src/i18n/messages.de.xlf b/apps/frontend-agent-console/src/i18n/messages.de.xlf index c25165d4..75a5e7be 100644 --- a/apps/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.de.xlf @@ -3942,6 +3942,30 @@ Remove relation Beziehung entfernen + + Manage workspace configuration + Workspace-Konfiguration verwalten + + + Manage Workspace Configuration + Workspace-Konfiguration verwalten + + + Workspace Configuration Settings + Workspace-Konfigurationseinstellungen + + + Enter override value + Override-Wert eingeben + + + Save override + Override speichern + + + Delete override + Override löschen + diff --git a/apps/frontend-agent-console/src/i18n/messages.xlf b/apps/frontend-agent-console/src/i18n/messages.xlf index 2c521410..2a7ebf5f 100644 --- a/apps/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.xlf @@ -2955,6 +2955,24 @@ Delete + + Manage workspace configuration + + + Manage Workspace Configuration + + + Workspace Configuration Settings + + + Enter override value + + + Save override + + + Delete override + diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-workspace-configuration-overrides.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-workspace-configuration-overrides.mmd new file mode 100644 index 00000000..b1812ee8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-workspace-configuration-overrides.mmd @@ -0,0 +1,22 @@ +sequenceDiagram + participant Admin as WorkspaceOrGlobalAdmin + participant Controller as AgentController + participant Manager as AgentManager + participant Db as OverridesDb + participant Env as RuntimeProcessEnv + + Admin->>Controller: PUT /clients/{id}/configuration-overrides/{settingKey} + Controller->>Controller: ensureWorkspaceManagementAccess + Controller->>Manager: PUT /api/configuration-overrides/{settingKey} + Manager->>Db: upsert encrypted override value + Manager->>Env: process.env[envVarName]=overrideValue + Manager-->>Controller: setting {source=override} + Controller-->>Admin: 200 response + + Admin->>Controller: DELETE /clients/{id}/configuration-overrides/{settingKey} + Controller->>Controller: ensureWorkspaceManagementAccess + Controller->>Manager: DELETE /api/configuration-overrides/{settingKey} + Manager->>Db: delete override row + Manager->>Env: delete process.env[envVarName] + Manager-->>Controller: 204 + Controller-->>Admin: 204 diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index 84dcc0f3..6536d832 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -1863,6 +1863,93 @@ paths: description: No client access, or workspace management rights required '404': description: Client, agent, or environment variable not found + /clients/{id}/configuration-overrides: + get: + summary: List effective workspace configuration overrides (proxied) + description: Requires workspace management rights. + operationId: listClientWorkspaceConfigurationOverrides + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The UUID of the client + responses: + '200': + description: Effective workspace configuration settings + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WorkspaceConfigurationSettingResponseDto' + '403': + description: Workspace management rights required + '404': + description: Client not found + /clients/{id}/configuration-overrides/{settingKey}: + put: + summary: Create or update a workspace configuration override (proxied) + description: Requires workspace management rights. + operationId: upsertClientWorkspaceConfigurationOverride + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The UUID of the client + - in: path + name: settingKey + required: true + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertWorkspaceConfigurationOverrideDto' + responses: + '200': + description: Updated effective workspace configuration setting + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingResponseDto' + '400': + description: Invalid request or unsupported setting key + '403': + description: Workspace management rights required + '404': + description: Client not found + delete: + summary: Delete a workspace configuration override (proxied) + description: Requires workspace management rights. + operationId: deleteClientWorkspaceConfigurationOverride + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The UUID of the client + - in: path + name: settingKey + required: true + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + responses: + '204': + description: Override deleted successfully + '403': + description: Workspace management rights required + '404': + description: Client not found /clients/{id}/agents/{agentId}/vcs/status: get: summary: Get git status (proxied) @@ -4253,6 +4340,40 @@ components: updatedAt: type: string format: date-time + WorkspaceConfigurationSettingKey: + type: string + enum: + [ + gitRepositoryUrl, + gitUsername, + gitToken, + gitPassword, + gitPrivateKey, + cursorApiKey, + agentDefaultImage, + ] + UpsertWorkspaceConfigurationOverrideDto: + type: object + required: [value] + properties: + value: + type: string + description: Override value + WorkspaceConfigurationSettingResponseDto: + type: object + required: [settingKey, envVarName, source, hasOverride] + properties: + settingKey: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + envVarName: + type: string + value: + type: string + source: + type: string + enum: [override, default_env, unset] + hasOverride: + type: boolean AddClientUserDto: type: object required: [email, role] diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.spec.ts new file mode 100644 index 00000000..337cd03c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.spec.ts @@ -0,0 +1,48 @@ +import { ClientUsersRepository } from '@forepath/identity/backend'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; + +import { ClientsConfigurationOverridesController } from './clients-configuration-overrides.controller'; + +jest.mock('@forepath/identity/backend', () => { + const actual = jest.requireActual('@forepath/identity/backend'); + + return { + ...actual, + ensureClientAccess: jest.fn().mockResolvedValue(undefined), + ensureWorkspaceManagementAccess: jest.fn().mockResolvedValue(undefined), + }; +}); + +describe('ClientsConfigurationOverridesController', () => { + let controller: ClientsConfigurationOverridesController; + let proxyService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClientsConfigurationOverridesController], + providers: [ + { + provide: ClientWorkspaceConfigurationOverridesProxyService, + useValue: { + getConfigurationOverrides: jest.fn().mockResolvedValue([]), + upsertConfigurationOverride: jest.fn().mockResolvedValue({ settingKey: 'gitToken', value: 'abc' }), + deleteConfigurationOverride: jest.fn().mockResolvedValue(undefined), + }, + }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + ], + }).compile(); + + controller = module.get(ClientsConfigurationOverridesController); + proxyService = module.get(ClientWorkspaceConfigurationOverridesProxyService); + }); + + it('proxies list', async () => { + await controller.getConfigurationOverrides('8f6e8fc8-7a18-4f96-bd81-cd4fca6e8ea8'); + expect(proxyService.getConfigurationOverrides).toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts new file mode 100644 index 00000000..e79a085f --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients-configuration-overrides.controller.ts @@ -0,0 +1,55 @@ +import { + UpsertWorkspaceConfigurationOverrideDto, + WorkspaceConfigurationSettingResponseDto, +} from '@forepath/framework/backend/feature-agent-manager'; +import { + ClientUsersRepository, + ensureWorkspaceManagementAccess, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Put, Req } from '@nestjs/common'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; + +@Controller('clients/:id/configuration-overrides') +export class ClientsConfigurationOverridesController { + constructor( + private readonly proxyService: ClientWorkspaceConfigurationOverridesProxyService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + @Get() + async getConfigurationOverrides( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.proxyService.getConfigurationOverrides(clientId); + } + + @Put(':settingKey') + async upsertConfigurationOverride( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('settingKey') settingKey: string, + @Body() dto: UpsertWorkspaceConfigurationOverrideDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.proxyService.upsertConfigurationOverride(clientId, settingKey, dto); + } + + @Delete(':settingKey') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteConfigurationOverride( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('settingKey') settingKey: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + await this.proxyService.deleteConfigurationOverride(clientId, settingKey); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts index f63511c8..8156d5a3 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -21,6 +21,7 @@ import { ClientAgentAutonomyDirectoryController } from '../controllers/client-ag import { ClientAgentAutonomyController } from '../controllers/client-agent-autonomy.controller'; import { ClientStatisticsController } from '../controllers/client-statistics.controller'; import { ClientsAgentAutomationProxyController } from '../controllers/clients-agent-automation-proxy.controller'; +import { ClientsConfigurationOverridesController } from '../controllers/clients-configuration-overrides.controller'; import { ClientsDeploymentsController } from '../controllers/clients-deployments.controller'; import { ClientsVcsController } from '../controllers/clients-vcs.controller'; import { ClientsController } from '../controllers/clients.controller'; @@ -58,6 +59,7 @@ import { ClientAgentFileSystemProxyService } from '../services/client-agent-file import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; +import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; import { ClientsService } from '../services/clients.service'; import { KnowledgeBoardRealtimeService } from '../services/knowledge-board-realtime.service'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; @@ -106,6 +108,7 @@ const authMethod = getAuthenticationMethod(); ], controllers: [ ClientsController, + ClientsConfigurationOverridesController, ClientsVcsController, ClientsDeploymentsController, ClientStatisticsController, @@ -136,6 +139,7 @@ const authMethod = getAuthenticationMethod(); ClientAgentVcsProxyService, ClientAgentDeploymentsProxyService, ClientAgentEnvironmentVariablesProxyService, + ClientWorkspaceConfigurationOverridesProxyService, ClientAgentCredentialsRepository, ClientAgentCredentialsService, SocketAuthService, @@ -178,6 +182,7 @@ const authMethod = getAuthenticationMethod(); ClientAgentVcsProxyService, ClientAgentDeploymentsProxyService, ClientAgentEnvironmentVariablesProxyService, + ClientWorkspaceConfigurationOverridesProxyService, ClientAgentCredentialsRepository, ClientAgentCredentialsService, ClientsGateway, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.spec.ts new file mode 100644 index 00000000..f823545e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.spec.ts @@ -0,0 +1,55 @@ +import { AuthenticationType } from '@forepath/identity/backend'; +import { Test, TestingModule } from '@nestjs/testing'; +import axios from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; + +import { ClientWorkspaceConfigurationOverridesProxyService } from './client-workspace-configuration-overrides-proxy.service'; +import { ClientsService } from './clients.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('ClientWorkspaceConfigurationOverridesProxyService', () => { + let service: ClientWorkspaceConfigurationOverridesProxyService; + let clientsRepository: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientWorkspaceConfigurationOverridesProxyService, + { + provide: ClientsRepository, + useValue: { + findByIdOrThrow: jest.fn().mockResolvedValue({ + id: 'client-1', + endpoint: 'http://localhost:3000', + authenticationType: AuthenticationType.API_KEY, + apiKey: 'test-key', + }), + }, + }, + { + provide: ClientsService, + useValue: { getAccessToken: jest.fn().mockResolvedValue('token') }, + }, + ], + }).compile(); + + service = module.get(ClientWorkspaceConfigurationOverridesProxyService); + clientsRepository = module.get(ClientsRepository); + mockedAxios.request.mockReset(); + }); + + it('loads configuration overrides via proxied endpoint', async () => { + mockedAxios.request.mockResolvedValue({ + status: 200, + data: [{ settingKey: 'gitToken', value: 'a', source: 'override', hasOverride: true, envVarName: 'GIT_TOKEN' }], + } as any); + + const result = await service.getConfigurationOverrides('client-1'); + + expect(result[0].settingKey).toBe('gitToken'); + expect(clientsRepository.findByIdOrThrow).toHaveBeenCalledWith('client-1'); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.ts new file mode 100644 index 00000000..42b8252c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-workspace-configuration-overrides-proxy.service.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { + UpsertWorkspaceConfigurationOverrideDto, + WorkspaceConfigurationSettingResponseDto, +} from '@forepath/framework/backend/feature-agent-manager'; +import { AuthenticationType } from '@forepath/identity/backend'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; + +import { ClientsService } from './clients.service'; + +@Injectable() +export class ClientWorkspaceConfigurationOverridesProxyService { + private readonly logger = new Logger(ClientWorkspaceConfigurationOverridesProxyService.name); + + constructor( + private readonly clientsService: ClientsService, + private readonly clientsRepository: ClientsRepository, + ) {} + + private async getAuthHeader(clientId: string): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + + if (clientEntity.authenticationType === AuthenticationType.API_KEY) { + if (!clientEntity.apiKey) { + throw new BadRequestException('API key is not configured for this client'); + } + + return `Bearer ${clientEntity.apiKey}`; + } else if (clientEntity.authenticationType === AuthenticationType.KEYCLOAK) { + const token = await this.clientsService.getAccessToken(clientId); + + return `Bearer ${token}`; + } else { + throw new BadRequestException(`Unsupported authentication type: ${clientEntity.authenticationType}`); + } + } + + private buildConfigurationOverridesApiUrl(endpoint: string): string { + const baseUrl = endpoint.replace(/\/$/, ''); + + return `${baseUrl}/api/configuration-overrides`; + } + + private async makeRequest(clientId: string, config: AxiosRequestConfig): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + const authHeader = await this.getAuthHeader(clientId); + const baseUrl = this.buildConfigurationOverridesApiUrl(clientEntity.endpoint); + + try { + const response = await axios.request({ + ...config, + url: config.url ? `${baseUrl}${config.url}` : baseUrl, + headers: { + ...config.headers, + Authorization: authHeader, + 'Content-Type': 'application/json', + }, + validateStatus: (status) => status < 500, + httpsAgent: baseUrl.startsWith('https://') + ? new (require('https').Agent)({ + rejectUnauthorized: false, + }) + : undefined, + }); + + if (response.status >= 400) { + const errorMessage = (response.data as { message?: string })?.message || 'Request failed'; + + this.logger.error( + `Configuration override request to ${baseUrl}${config.url || ''} failed with status ${response.status}: ${errorMessage}`, + ); + + if (response.status === 404) { + throw new NotFoundException(errorMessage); + } + + throw new BadRequestException(errorMessage); + } + + return response.data; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + + const axiosError = error as AxiosError; + const errorMessage = + (axiosError.response?.data as { message?: string } | undefined)?.message || + axiosError.message || + 'Request failed'; + + this.logger.error(`Configuration override proxy error for ${baseUrl}${config.url || ''}: ${errorMessage}`); + throw new BadRequestException(errorMessage); + } + } + + async getConfigurationOverrides(clientId: string): Promise { + return await this.makeRequest(clientId, { + method: 'GET', + }); + } + + async upsertConfigurationOverride( + clientId: string, + settingKey: string, + dto: UpsertWorkspaceConfigurationOverrideDto, + ): Promise { + return await this.makeRequest(clientId, { + method: 'PUT', + url: `/${encodeURIComponent(settingKey)}`, + data: dto, + }); + } + + async deleteConfigurationOverride(clientId: string, settingKey: string): Promise { + await this.makeRequest(clientId, { + method: 'DELETE', + url: `/${encodeURIComponent(settingKey)}`, + }); + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml index 417eeedc..43941b29 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml @@ -666,6 +666,58 @@ paths: description: Environment variable deleted '404': description: Agent or environment variable not found + /configuration-overrides: + get: + summary: List effective workspace configuration overrides + operationId: listWorkspaceConfigurationOverrides + responses: + '200': + description: Effective workspace configuration settings + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WorkspaceConfigurationSettingResponseDto' + /configuration-overrides/{settingKey}: + put: + summary: Create or update a workspace configuration override + operationId: upsertWorkspaceConfigurationOverride + parameters: + - in: path + name: settingKey + required: true + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpsertWorkspaceConfigurationOverrideDto' + responses: + '200': + description: Updated effective workspace configuration setting + content: + application/json: + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingResponseDto' + '400': + description: Invalid request or unsupported setting key + delete: + summary: Delete a workspace configuration override + operationId: deleteWorkspaceConfigurationOverride + parameters: + - in: path + name: settingKey + required: true + schema: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + responses: + '204': + description: Override deleted successfully + '400': + description: Unsupported setting key /agents/{agentId}/vcs/status: get: summary: Get git status @@ -2110,6 +2162,40 @@ components: updatedAt: type: string format: date-time + WorkspaceConfigurationSettingKey: + type: string + enum: + [ + gitRepositoryUrl, + gitUsername, + gitToken, + gitPassword, + gitPrivateKey, + cursorApiKey, + agentDefaultImage, + ] + UpsertWorkspaceConfigurationOverrideDto: + type: object + required: [value] + properties: + value: + type: string + description: Override value + WorkspaceConfigurationSettingResponseDto: + type: object + required: [settingKey, envVarName, source, hasOverride] + properties: + settingKey: + $ref: '#/components/schemas/WorkspaceConfigurationSettingKey' + envVarName: + type: string + value: + type: string + source: + type: string + enum: [override, default_env, unset] + hasOverride: + type: boolean RegexFilterRuleResponseDto: type: object required: diff --git a/libs/domains/framework/backend/feature-agent-manager/src/index.ts b/libs/domains/framework/backend/feature-agent-manager/src/index.ts index d6387b18..c2b36bd8 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/index.ts @@ -27,6 +27,8 @@ export * from './lib/dto/unstage-files.dto'; export * from './lib/dto/update-agent.dto'; export * from './lib/dto/update-regex-filter-rule.dto'; export * from './lib/dto/update-environment-variable.dto'; +export * from './lib/dto/upsert-workspace-configuration-override.dto'; +export * from './lib/dto/workspace-configuration-setting-response.dto'; export * from './lib/dto/regex-filter-rule-response.dto'; export * from './lib/dto/write-file.dto'; export * from './lib/entities/agent-environment-variable.entity'; @@ -36,6 +38,7 @@ export * from './lib/entities/agent.entity'; export * from './lib/entities/deployment-configuration.entity'; export * from './lib/entities/deployment-run.entity'; export * from './lib/entities/regex-filter-rule.entity'; +export * from './lib/entities/workspace-configuration-override.entity'; export * from './lib/gateways/agents.gateway'; export * from './lib/modules/agents.module'; export * from './lib/repositories/agent-messages.repository'; @@ -45,8 +48,10 @@ export * from './lib/services/agents-vcs.service'; export * from './lib/services/agents.service'; export * from './lib/services/config.service'; export * from './lib/services/docker.service'; +export * from './lib/services/workspace-configuration-overrides.service'; export * from './lib/utils/agent-file-manager-context'; export * from './lib/utils/regex-filter-rule.utils'; +export * from './lib/constants/workspace-configuration-settings'; // Re-export PasswordService from identity for backward compatibility export { PasswordService } from '@forepath/identity/backend'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts new file mode 100644 index 00000000..247d7e64 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/workspace-configuration-settings.ts @@ -0,0 +1,25 @@ +export const WORKSPACE_CONFIGURATION_SETTINGS = [ + { settingKey: 'gitRepositoryUrl', envVarName: 'GIT_REPOSITORY_URL' }, + { settingKey: 'gitUsername', envVarName: 'GIT_USERNAME' }, + { settingKey: 'gitToken', envVarName: 'GIT_TOKEN' }, + { settingKey: 'gitPassword', envVarName: 'GIT_PASSWORD' }, + { settingKey: 'gitPrivateKey', envVarName: 'GIT_PRIVATE_KEY' }, + { settingKey: 'cursorApiKey', envVarName: 'CURSOR_API_KEY' }, + { settingKey: 'agentDefaultImage', envVarName: 'AGENT_DEFAULT_IMAGE' }, +] as const; + +export type WorkspaceConfigurationSetting = (typeof WORKSPACE_CONFIGURATION_SETTINGS)[number]; +export type WorkspaceConfigurationSettingKey = WorkspaceConfigurationSetting['settingKey']; + +export const WORKSPACE_CONFIGURATION_ENV_BY_SETTING: Readonly> = + Object.freeze( + WORKSPACE_CONFIGURATION_SETTINGS.reduce>((acc, entry) => { + acc[entry.settingKey] = entry.envVarName; + + return acc; + }, {}) as Record, + ); + +export function isWorkspaceConfigurationSettingKey(value: string): value is WorkspaceConfigurationSettingKey { + return value in WORKSPACE_CONFIGURATION_ENV_BY_SETTING; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.spec.ts new file mode 100644 index 00000000..c6c4f0a3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { WorkspaceConfigurationOverridesService } from '../services/workspace-configuration-overrides.service'; + +import { WorkspaceConfigurationOverridesController } from './workspace-configuration-overrides.controller'; + +describe('WorkspaceConfigurationOverridesController', () => { + let controller: WorkspaceConfigurationOverridesController; + let service: jest.Mocked; + const mockService = { + getEffectiveSettings: jest.fn(), + upsertOverride: jest.fn(), + deleteOverride: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [WorkspaceConfigurationOverridesController], + providers: [{ provide: WorkspaceConfigurationOverridesService, useValue: mockService }], + }).compile(); + + controller = module.get(WorkspaceConfigurationOverridesController); + service = module.get(WorkspaceConfigurationOverridesService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('lists effective settings', async () => { + service.getEffectiveSettings.mockResolvedValue([{ settingKey: 'gitToken' } as any]); + + const result = await controller.getConfigurationOverrides(); + + expect(result).toHaveLength(1); + expect(service.getEffectiveSettings).toHaveBeenCalled(); + }); + + it('upserts a setting', async () => { + service.upsertOverride.mockResolvedValue({ settingKey: 'gitToken', value: 'abc' } as any); + + const result = await controller.upsertConfigurationOverride('gitToken', { value: 'abc' }); + + expect(result.settingKey).toBe('gitToken'); + expect(service.upsertOverride).toHaveBeenCalledWith('gitToken', 'abc'); + }); + + it('deletes a setting', async () => { + service.deleteOverride.mockResolvedValue(undefined); + + await controller.deleteConfigurationOverride('gitToken'); + + expect(service.deleteOverride).toHaveBeenCalledWith('gitToken'); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.ts new file mode 100644 index 00000000..342a0db7 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/workspace-configuration-overrides.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put } from '@nestjs/common'; + +import { UpsertWorkspaceConfigurationOverrideDto } from '../dto/upsert-workspace-configuration-override.dto'; +import { WorkspaceConfigurationSettingResponseDto } from '../dto/workspace-configuration-setting-response.dto'; +import { WorkspaceConfigurationOverridesService } from '../services/workspace-configuration-overrides.service'; + +@Controller('configuration-overrides') +export class WorkspaceConfigurationOverridesController { + constructor(private readonly service: WorkspaceConfigurationOverridesService) {} + + @Get() + async getConfigurationOverrides(): Promise { + return await this.service.getEffectiveSettings(); + } + + @Put(':settingKey') + async upsertConfigurationOverride( + @Param('settingKey') settingKey: string, + @Body() dto: UpsertWorkspaceConfigurationOverrideDto, + ): Promise { + return await this.service.upsertOverride(settingKey, dto.value); + } + + @Delete(':settingKey') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteConfigurationOverride(@Param('settingKey') settingKey: string): Promise { + await this.service.deleteOverride(settingKey); + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts new file mode 100644 index 00000000..a735897c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/upsert-workspace-configuration-override.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpsertWorkspaceConfigurationOverrideDto { + @IsString({ message: 'Value must be a string' }) + @IsNotEmpty({ message: 'Value is required' }) + value!: string; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/workspace-configuration-setting-response.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/workspace-configuration-setting-response.dto.ts new file mode 100644 index 00000000..8e48f9d6 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/workspace-configuration-setting-response.dto.ts @@ -0,0 +1,11 @@ +import { WorkspaceConfigurationSettingKey } from '../constants/workspace-configuration-settings'; + +export type WorkspaceConfigurationValueSource = 'override' | 'default_env' | 'unset'; + +export class WorkspaceConfigurationSettingResponseDto { + settingKey!: WorkspaceConfigurationSettingKey; + envVarName!: string; + value?: string; + source!: WorkspaceConfigurationValueSource; + hasOverride!: boolean; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts new file mode 100644 index 00000000..f5fe927e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/entities/workspace-configuration-override.entity.ts @@ -0,0 +1,27 @@ +import { createAes256GcmTransformer } from '@forepath/shared/backend'; +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +import { WorkspaceConfigurationSettingKey } from '../constants/workspace-configuration-settings'; + +@Entity('workspace_configuration_overrides') +@Index('IDX_workspace_configuration_overrides_setting_key_unique', ['settingKey'], { unique: true }) +export class WorkspaceConfigurationOverrideEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'varchar', length: 64, name: 'setting_key' }) + settingKey!: WorkspaceConfigurationSettingKey; + + @Column({ + type: 'text', + name: 'value', + transformer: createAes256GcmTransformer(), + }) + value!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts index 34c7e57c..d241e2f1 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts @@ -11,6 +11,7 @@ import { AgentEntity } from '../entities/agent.entity'; import { DeploymentConfigurationEntity } from '../entities/deployment-configuration.entity'; import { DeploymentRunEntity } from '../entities/deployment-run.entity'; import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; +import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { CursorAgentProvider } from '../providers/agents/cursor-agent.provider'; @@ -68,6 +69,8 @@ describe('AgentsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(RegexFilterRuleEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(WorkspaceConfigurationOverrideEntity)) + .useValue(mockRepository) .compile(); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 3bbcb182..712133fe 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -10,6 +10,7 @@ import { AgentsVcsController } from '../controllers/agents-vcs.controller'; import { AgentsVerificationController } from '../controllers/agents-verification.controller'; import { AgentsController } from '../controllers/agents.controller'; import { ConfigController } from '../controllers/config.controller'; +import { WorkspaceConfigurationOverridesController } from '../controllers/workspace-configuration-overrides.controller'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; import { AgentMessageEventEntity } from '../entities/agent-message-event.entity'; import { AgentMessageEntity } from '../entities/agent-message.entity'; @@ -17,6 +18,7 @@ import { AgentEntity } from '../entities/agent.entity'; import { DeploymentConfigurationEntity } from '../entities/deployment-configuration.entity'; import { DeploymentRunEntity } from '../entities/deployment-run.entity'; import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; +import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { CursorAgentProvider } from '../providers/agents/cursor-agent.provider'; @@ -39,6 +41,7 @@ import { AgentsRepository } from '../repositories/agents.repository'; import { DeploymentConfigurationsRepository } from '../repositories/deployment-configurations.repository'; import { DeploymentRunsRepository } from '../repositories/deployment-runs.repository'; import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; +import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; import { AgentEnvironmentVariablesService } from '../services/agent-environment-variables.service'; import { AgentFileSystemService } from '../services/agent-file-system.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; @@ -53,6 +56,7 @@ import { DockerService } from '../services/docker.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; import { RegexFilterRulesCacheService } from '../services/regex-filter-rules-cache.service'; import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules-evaluate.service'; +import { WorkspaceConfigurationOverridesService } from '../services/workspace-configuration-overrides.service'; /** * Module for agent management feature. @@ -68,6 +72,7 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- DeploymentConfigurationEntity, DeploymentRunEntity, RegexFilterRuleEntity, + WorkspaceConfigurationOverrideEntity, ]), ], controllers: [ @@ -79,6 +84,7 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- AgentsEnvironmentVariablesController, AgentsFiltersController, ConfigController, + WorkspaceConfigurationOverridesController, ], providers: [ AgentsGateway, @@ -116,6 +122,8 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- RegexFilterRulesCacheService, RegexFilterRulesEvaluateService, AgentsFiltersService, + WorkspaceConfigurationOverridesRepository, + WorkspaceConfigurationOverridesService, DatabaseRegexIncomingChatFilter, DatabaseRegexOutgoingChatFilter, { diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts new file mode 100644 index 00000000..5ebf26a3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/workspace-configuration-overrides.repository.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WorkspaceConfigurationSettingKey } from '../constants/workspace-configuration-settings'; +import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; + +@Injectable() +export class WorkspaceConfigurationOverridesRepository { + constructor( + @InjectRepository(WorkspaceConfigurationOverrideEntity) + private readonly repository: Repository, + ) {} + + async findAll(): Promise { + return await this.repository.find({ order: { settingKey: 'ASC' } }); + } + + async findBySettingKey( + settingKey: WorkspaceConfigurationSettingKey, + ): Promise { + return await this.repository.findOne({ where: { settingKey } }); + } + + async upsert( + settingKey: WorkspaceConfigurationSettingKey, + value: string, + ): Promise { + const existing = await this.findBySettingKey(settingKey); + + if (existing) { + existing.value = value; + + return await this.repository.save(existing); + } + + return await this.repository.save(this.repository.create({ settingKey, value })); + } + + async deleteBySettingKey(settingKey: WorkspaceConfigurationSettingKey): Promise { + const result = await this.repository.delete({ settingKey }); + + return result.affected ?? 0; + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.spec.ts new file mode 100644 index 00000000..2adc11d3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.spec.ts @@ -0,0 +1,105 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; + +import { WorkspaceConfigurationOverridesService } from './workspace-configuration-overrides.service'; + +describe('WorkspaceConfigurationOverridesService', () => { + let service: WorkspaceConfigurationOverridesService; + let repository: jest.Mocked; + const originalEnv = process.env; + + beforeEach(() => { + repository = { + findAll: jest.fn(), + findBySettingKey: jest.fn(), + upsert: jest.fn(), + deleteBySettingKey: jest.fn(), + } as unknown as jest.Mocked; + service = new WorkspaceConfigurationOverridesService(repository); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + jest.clearAllMocks(); + }); + + it('resolves effective settings preferring overrides over env defaults', async () => { + process.env.GIT_REPOSITORY_URL = 'https://default.example/repo.git'; + repository.findAll.mockResolvedValue([ + { + id: '1', + settingKey: 'gitRepositoryUrl', + value: 'https://override.example/repo.git', + createdAt: new Date(), + updatedAt: new Date(), + } as any, + ]); + + const settings = await service.getEffectiveSettings(); + const repoSetting = settings.find((entry) => entry.settingKey === 'gitRepositoryUrl'); + + expect(repoSetting).toEqual( + expect.objectContaining({ + value: 'https://override.example/repo.git', + source: 'override', + hasOverride: true, + }), + ); + }); + + it('upserts override and mutates process.env', async () => { + repository.upsert.mockResolvedValue({ + id: '1', + settingKey: 'cursorApiKey', + value: 'sk-123', + createdAt: new Date(), + updatedAt: new Date(), + } as any); + + await service.upsertOverride('cursorApiKey', 'sk-123'); + + expect(repository.upsert).toHaveBeenCalledWith('cursorApiKey', 'sk-123'); + expect(process.env.CURSOR_API_KEY).toBe('sk-123'); + }); + + it('loads persisted overrides into process.env on module init', async () => { + repository.findAll.mockResolvedValue([ + { + id: '1', + settingKey: 'gitToken', + value: 'boot-token', + createdAt: new Date(), + updatedAt: new Date(), + } as any, + { + id: '2', + settingKey: 'cursorApiKey', + value: 'boot-cursor-key', + createdAt: new Date(), + updatedAt: new Date(), + } as any, + ]); + + await service.onModuleInit(); + + expect(process.env.GIT_TOKEN).toBe('boot-token'); + expect(process.env.CURSOR_API_KEY).toBe('boot-cursor-key'); + }); + + it('deletes override and removes process env value', async () => { + process.env.GIT_TOKEN = 'old'; + repository.deleteBySettingKey.mockResolvedValue(1); + + await service.deleteOverride('gitToken'); + + expect(repository.deleteBySettingKey).toHaveBeenCalledWith('gitToken'); + expect(process.env.GIT_TOKEN).toBeUndefined(); + }); + + it('rejects unknown setting keys', async () => { + await expect(service.upsertOverride('unknownKey', 'v')).rejects.toBeInstanceOf(BadRequestException); + await expect(service.deleteOverride('unknownKey')).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.ts new file mode 100644 index 00000000..a9abe08b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.ts @@ -0,0 +1,93 @@ +import { BadRequestException, Injectable, OnModuleInit } from '@nestjs/common'; + +import { + isWorkspaceConfigurationSettingKey, + WORKSPACE_CONFIGURATION_ENV_BY_SETTING, + WORKSPACE_CONFIGURATION_SETTINGS, + WorkspaceConfigurationSettingKey, +} from '../constants/workspace-configuration-settings'; +import { + WorkspaceConfigurationSettingResponseDto, + WorkspaceConfigurationValueSource, +} from '../dto/workspace-configuration-setting-response.dto'; +import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; + +@Injectable() +export class WorkspaceConfigurationOverridesService implements OnModuleInit { + constructor(private readonly repository: WorkspaceConfigurationOverridesRepository) {} + + async onModuleInit(): Promise { + await this.applyOverridesToProcessEnv(); + } + + async getEffectiveSettings(): Promise { + const overrides = await this.repository.findAll(); + const overrideByKey = new Map(overrides.map((entry) => [entry.settingKey, entry.value])); + + return WORKSPACE_CONFIGURATION_SETTINGS.map((setting) => { + const overrideValue = overrideByKey.get(setting.settingKey); + const defaultValue = process.env[setting.envVarName]; + const value = overrideValue ?? defaultValue; + let source: WorkspaceConfigurationValueSource = 'unset'; + + if (overrideValue !== undefined) { + source = 'override'; + } else if (defaultValue !== undefined) { + source = 'default_env'; + } + + return { + settingKey: setting.settingKey, + envVarName: setting.envVarName, + value, + source, + hasOverride: overrideValue !== undefined, + }; + }); + } + + async upsertOverride(settingKeyRaw: string, value: string): Promise { + const settingKey = this.validateSettingKey(settingKeyRaw); + const envVarName = WORKSPACE_CONFIGURATION_ENV_BY_SETTING[settingKey]; + + await this.repository.upsert(settingKey, value); + process.env[envVarName] = value; + + return { + settingKey, + envVarName, + value, + source: 'override', + hasOverride: true, + }; + } + + async deleteOverride(settingKeyRaw: string): Promise { + const settingKey = this.validateSettingKey(settingKeyRaw); + const envVarName = WORKSPACE_CONFIGURATION_ENV_BY_SETTING[settingKey]; + + await this.repository.deleteBySettingKey(settingKey); + delete process.env[envVarName]; + } + + private validateSettingKey(settingKeyRaw: string): WorkspaceConfigurationSettingKey { + if (!isWorkspaceConfigurationSettingKey(settingKeyRaw)) { + throw new BadRequestException(`Unsupported setting key: ${settingKeyRaw}`); + } + + return settingKeyRaw; + } + + private async applyOverridesToProcessEnv(): Promise { + const overrides = await this.repository.findAll(); + + for (const override of overrides) { + if (!isWorkspaceConfigurationSettingKey(override.settingKey)) { + continue; + } + + const envVarName = WORKSPACE_CONFIGURATION_ENV_BY_SETTING[override.settingKey]; + process.env[envVarName] = override.value; + } + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts index fe271a76..1a58a063 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts @@ -112,6 +112,7 @@ export * from './lib/services/files.service'; export * from './lib/services/statistics.service'; export * from './lib/services/tickets.service'; export * from './lib/services/vcs.service'; +export * from './lib/services/workspace-config.service'; export * from './lib/state/agents/agents.actions'; export * from './lib/state/agents/agents.effects'; export * from './lib/state/agents/agents.facade'; @@ -213,3 +214,9 @@ export * from './lib/state/vcs/vcs.facade'; export * from './lib/state/vcs/vcs.reducer'; export * from './lib/state/vcs/vcs.selectors'; export * from './lib/state/vcs/vcs.types'; +export * from './lib/state/workspace-config/workspace-config.actions'; +export * from './lib/state/workspace-config/workspace-config.effects'; +export * from './lib/state/workspace-config/workspace-config.facade'; +export * from './lib/state/workspace-config/workspace-config.reducer'; +export * from './lib/state/workspace-config/workspace-config.selectors'; +export * from './lib/state/workspace-config/workspace-config.types'; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.spec.ts new file mode 100644 index 00000000..538d46d2 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.spec.ts @@ -0,0 +1,38 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@forepath/framework/frontend/util-configuration'; + +import { WorkspaceConfigService } from './workspace-config.service'; + +describe('WorkspaceConfigService', () => { + let service: WorkspaceConfigService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + WorkspaceConfigService, + { + provide: ENVIRONMENT, + useValue: { controller: { restApiUrl: 'http://localhost:3000' } }, + }, + ], + }); + + service = TestBed.inject(WorkspaceConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('calls list endpoint', () => { + service.listConfigurationOverrides('client-1').subscribe(); + const req = httpMock.expectOne('http://localhost:3000/clients/client-1/configuration-overrides'); + + expect(req.request.method).toBe('GET'); + req.flush([]); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.ts new file mode 100644 index 00000000..96e2c677 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/workspace-config.service.ts @@ -0,0 +1,44 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import type { Environment } from '@forepath/framework/frontend/util-configuration'; +import { ENVIRONMENT } from '@forepath/framework/frontend/util-configuration'; +import { Observable } from 'rxjs'; + +import type { + UpsertWorkspaceConfigurationOverrideDto, + WorkspaceConfigurationSettingResponseDto, + WorkspaceConfigurationSettingKey, +} from '../state/workspace-config/workspace-config.types'; + +@Injectable({ + providedIn: 'root', +}) +export class WorkspaceConfigService { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private get apiUrl(): string { + return this.environment.controller.restApiUrl; + } + + listConfigurationOverrides(clientId: string): Observable { + return this.http.get( + `${this.apiUrl}/clients/${clientId}/configuration-overrides`, + ); + } + + upsertConfigurationOverride( + clientId: string, + settingKey: WorkspaceConfigurationSettingKey, + dto: UpsertWorkspaceConfigurationOverrideDto, + ): Observable { + return this.http.put( + `${this.apiUrl}/clients/${clientId}/configuration-overrides/${settingKey}`, + dto, + ); + } + + deleteConfigurationOverride(clientId: string, settingKey: WorkspaceConfigurationSettingKey): Observable { + return this.http.delete(`${this.apiUrl}/clients/${clientId}/configuration-overrides/${settingKey}`); + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/knowledge-board-socket/knowledge-board-socket.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/knowledge-board-socket/knowledge-board-socket.effects.spec.ts index 2d93670f..6caf7ac0 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/knowledge-board-socket/knowledge-board-socket.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/knowledge-board-socket/knowledge-board-socket.effects.spec.ts @@ -2,6 +2,7 @@ import { Subject } from 'rxjs'; import { io, Socket } from 'socket.io-client'; import { loadKnowledgeRelations, loadKnowledgeTree } from '../knowledge/knowledge.actions'; + import { connectKnowledgeBoardSocket, connectKnowledgeBoardSocketSuccess, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.actions.ts new file mode 100644 index 00000000..56c53e3d --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.actions.ts @@ -0,0 +1,61 @@ +import { createAction, props } from '@ngrx/store'; + +import type { + UpsertWorkspaceConfigurationOverrideDto, + WorkspaceConfigurationSettingResponseDto, + WorkspaceConfigurationSettingKey, +} from './workspace-config.types'; + +export const loadWorkspaceConfigurationOverrides = createAction( + '[Workspace Config] Load Overrides', + props<{ clientId: string }>(), +); + +export const loadWorkspaceConfigurationOverridesSuccess = createAction( + '[Workspace Config] Load Overrides Success', + props<{ clientId: string; settings: WorkspaceConfigurationSettingResponseDto[] }>(), +); + +export const loadWorkspaceConfigurationOverridesFailure = createAction( + '[Workspace Config] Load Overrides Failure', + props<{ clientId: string; error: string }>(), +); + +export const upsertWorkspaceConfigurationOverride = createAction( + '[Workspace Config] Upsert Override', + props<{ + clientId: string; + settingKey: WorkspaceConfigurationSettingKey; + dto: UpsertWorkspaceConfigurationOverrideDto; + }>(), +); + +export const upsertWorkspaceConfigurationOverrideSuccess = createAction( + '[Workspace Config] Upsert Override Success', + props<{ clientId: string; setting: WorkspaceConfigurationSettingResponseDto }>(), +); + +export const upsertWorkspaceConfigurationOverrideFailure = createAction( + '[Workspace Config] Upsert Override Failure', + props<{ clientId: string; settingKey: WorkspaceConfigurationSettingKey; error: string }>(), +); + +export const deleteWorkspaceConfigurationOverride = createAction( + '[Workspace Config] Delete Override', + props<{ clientId: string; settingKey: WorkspaceConfigurationSettingKey }>(), +); + +export const deleteWorkspaceConfigurationOverrideSuccess = createAction( + '[Workspace Config] Delete Override Success', + props<{ clientId: string; settingKey: WorkspaceConfigurationSettingKey }>(), +); + +export const deleteWorkspaceConfigurationOverrideFailure = createAction( + '[Workspace Config] Delete Override Failure', + props<{ clientId: string; settingKey: WorkspaceConfigurationSettingKey; error: string }>(), +); + +export const clearWorkspaceConfigurationOverrides = createAction( + '[Workspace Config] Clear Overrides', + props<{ clientId: string }>(), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.spec.ts new file mode 100644 index 00000000..88b92e88 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Observable, of, throwError } from 'rxjs'; + +import { WorkspaceConfigService } from '../../services/workspace-config.service'; + +import { + loadWorkspaceConfigurationOverrides, + loadWorkspaceConfigurationOverridesFailure, + loadWorkspaceConfigurationOverridesSuccess, +} from './workspace-config.actions'; +import { loadWorkspaceConfigurationOverrides$ } from './workspace-config.effects'; + +describe('workspace-config effects', () => { + let actions$: Observable; + let service: jest.Mocked; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockActions(() => actions$), + { + provide: WorkspaceConfigService, + useValue: { + listConfigurationOverrides: jest.fn(), + }, + }, + ], + }); + service = TestBed.inject(WorkspaceConfigService) as jest.Mocked; + }); + + it('emits success when load works', (done) => { + actions$ = of(loadWorkspaceConfigurationOverrides({ clientId: 'c1' })); + service.listConfigurationOverrides.mockReturnValue(of([])); + + loadWorkspaceConfigurationOverrides$(actions$, service).subscribe((action) => { + expect(action).toEqual(loadWorkspaceConfigurationOverridesSuccess({ clientId: 'c1', settings: [] })); + done(); + }); + }); + + it('emits failure when load fails', (done) => { + actions$ = of(loadWorkspaceConfigurationOverrides({ clientId: 'c1' })); + service.listConfigurationOverrides.mockReturnValue(throwError(() => new Error('boom'))); + + loadWorkspaceConfigurationOverrides$(actions$, service).subscribe((action) => { + expect(action).toEqual(loadWorkspaceConfigurationOverridesFailure({ clientId: 'c1', error: 'boom' })); + done(); + }); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.ts new file mode 100644 index 00000000..98778b5d --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.effects.ts @@ -0,0 +1,94 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, exhaustMap, map, of, switchMap } from 'rxjs'; + +import { WorkspaceConfigService } from '../../services/workspace-config.service'; + +import { + deleteWorkspaceConfigurationOverride, + deleteWorkspaceConfigurationOverrideFailure, + deleteWorkspaceConfigurationOverrideSuccess, + loadWorkspaceConfigurationOverrides, + loadWorkspaceConfigurationOverridesFailure, + loadWorkspaceConfigurationOverridesSuccess, + upsertWorkspaceConfigurationOverride, + upsertWorkspaceConfigurationOverrideFailure, + upsertWorkspaceConfigurationOverrideSuccess, +} from './workspace-config.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message); + } + + return 'An unexpected error occurred'; +} + +export const loadWorkspaceConfigurationOverrides$ = createEffect( + (actions$ = inject(Actions), service = inject(WorkspaceConfigService)) => { + return actions$.pipe( + ofType(loadWorkspaceConfigurationOverrides), + switchMap(({ clientId }) => + service.listConfigurationOverrides(clientId).pipe( + map((settings) => loadWorkspaceConfigurationOverridesSuccess({ clientId, settings })), + catchError((error) => + of(loadWorkspaceConfigurationOverridesFailure({ clientId, error: normalizeError(error) })), + ), + ), + ), + ); + }, + { functional: true }, +); + +export const upsertWorkspaceConfigurationOverride$ = createEffect( + (actions$ = inject(Actions), service = inject(WorkspaceConfigService)) => { + return actions$.pipe( + ofType(upsertWorkspaceConfigurationOverride), + exhaustMap(({ clientId, settingKey, dto }) => + service.upsertConfigurationOverride(clientId, settingKey, dto).pipe( + map((setting) => upsertWorkspaceConfigurationOverrideSuccess({ clientId, setting })), + catchError((error) => + of(upsertWorkspaceConfigurationOverrideFailure({ clientId, settingKey, error: normalizeError(error) })), + ), + ), + ), + ); + }, + { functional: true }, +); + +export const deleteWorkspaceConfigurationOverride$ = createEffect( + (actions$ = inject(Actions), service = inject(WorkspaceConfigService)) => { + return actions$.pipe( + ofType(deleteWorkspaceConfigurationOverride), + exhaustMap(({ clientId, settingKey }) => + service.deleteConfigurationOverride(clientId, settingKey).pipe( + map(() => deleteWorkspaceConfigurationOverrideSuccess({ clientId, settingKey })), + catchError((error) => + of(deleteWorkspaceConfigurationOverrideFailure({ clientId, settingKey, error: normalizeError(error) })), + ), + ), + ), + ); + }, + { functional: true }, +); + +export const reloadWorkspaceConfigurationAfterMutation$ = createEffect( + (actions$ = inject(Actions)) => { + return actions$.pipe( + ofType(upsertWorkspaceConfigurationOverrideSuccess, deleteWorkspaceConfigurationOverrideSuccess), + map(({ clientId }) => loadWorkspaceConfigurationOverrides({ clientId })), + ); + }, + { functional: true }, +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.spec.ts new file mode 100644 index 00000000..4f4a29f7 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; + +import { + deleteWorkspaceConfigurationOverride, + loadWorkspaceConfigurationOverrides, + upsertWorkspaceConfigurationOverride, +} from './workspace-config.actions'; +import { WorkspaceConfigFacade } from './workspace-config.facade'; + +describe('WorkspaceConfigFacade', () => { + let facade: WorkspaceConfigFacade; + let store: Store; + let dispatchSpy: jest.SpyInstance; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [WorkspaceConfigFacade, provideMockStore()], + }); + + facade = TestBed.inject(WorkspaceConfigFacade); + store = TestBed.inject(Store); + dispatchSpy = jest.spyOn(store, 'dispatch'); + }); + + it('dispatches load action', () => { + facade.loadSettings('c1'); + expect(dispatchSpy).toHaveBeenCalledWith(loadWorkspaceConfigurationOverrides({ clientId: 'c1' })); + }); + + it('dispatches upsert action', () => { + facade.upsertSetting('c1', 'gitToken', 'abc'); + expect(dispatchSpy).toHaveBeenCalledWith( + upsertWorkspaceConfigurationOverride({ clientId: 'c1', settingKey: 'gitToken', dto: { value: 'abc' } }), + ); + }); + + it('dispatches delete action', () => { + facade.deleteSettingOverride('c1', 'gitToken'); + expect(dispatchSpy).toHaveBeenCalledWith( + deleteWorkspaceConfigurationOverride({ clientId: 'c1', settingKey: 'gitToken' }), + ); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.ts new file mode 100644 index 00000000..1a863974 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.ts @@ -0,0 +1,69 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { + clearWorkspaceConfigurationOverrides, + deleteWorkspaceConfigurationOverride, + loadWorkspaceConfigurationOverrides, + upsertWorkspaceConfigurationOverride, +} from './workspace-config.actions'; +import { + selectWorkspaceConfigurationError, + selectWorkspaceConfigurationLoading, + selectWorkspaceConfigurationSettingDeleting, + selectWorkspaceConfigurationSettingError, + selectWorkspaceConfigurationSettings, + selectWorkspaceConfigurationSettingSaving, +} from './workspace-config.selectors'; +import type { + WorkspaceConfigurationSettingKey, + WorkspaceConfigurationSettingResponseDto, +} from './workspace-config.types'; + +@Injectable({ + providedIn: 'root', +}) +export class WorkspaceConfigFacade { + private readonly store = inject(Store); + + getSettings$(clientId: string): Observable { + return this.store.select(selectWorkspaceConfigurationSettings(clientId)); + } + + getLoading$(clientId: string): Observable { + return this.store.select(selectWorkspaceConfigurationLoading(clientId)); + } + + getError$(clientId: string): Observable { + return this.store.select(selectWorkspaceConfigurationError(clientId)); + } + + isSavingSetting$(clientId: string, settingKey: WorkspaceConfigurationSettingKey): Observable { + return this.store.select(selectWorkspaceConfigurationSettingSaving(clientId, settingKey)); + } + + isDeletingSetting$(clientId: string, settingKey: WorkspaceConfigurationSettingKey): Observable { + return this.store.select(selectWorkspaceConfigurationSettingDeleting(clientId, settingKey)); + } + + getSettingError$(clientId: string, settingKey: WorkspaceConfigurationSettingKey): Observable { + return this.store.select(selectWorkspaceConfigurationSettingError(clientId, settingKey)); + } + + loadSettings(clientId: string): void { + this.store.dispatch(loadWorkspaceConfigurationOverrides({ clientId })); + } + + upsertSetting(clientId: string, settingKey: WorkspaceConfigurationSettingKey, value: string): void { + this.store.dispatch(upsertWorkspaceConfigurationOverride({ clientId, settingKey, dto: { value } })); + } + + deleteSettingOverride(clientId: string, settingKey: WorkspaceConfigurationSettingKey): void { + this.store.dispatch(deleteWorkspaceConfigurationOverride({ clientId, settingKey })); + } + + clearSettings(clientId: string): void { + this.store.dispatch(clearWorkspaceConfigurationOverrides({ clientId })); + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.spec.ts new file mode 100644 index 00000000..db3cae8b --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.spec.ts @@ -0,0 +1,52 @@ +import { + loadWorkspaceConfigurationOverrides, + loadWorkspaceConfigurationOverridesSuccess, + upsertWorkspaceConfigurationOverrideSuccess, +} from './workspace-config.actions'; +import { initialWorkspaceConfigState, workspaceConfigReducer } from './workspace-config.reducer'; + +describe('workspaceConfigReducer', () => { + it('sets loading on load action', () => { + const state = workspaceConfigReducer( + initialWorkspaceConfigState, + loadWorkspaceConfigurationOverrides({ clientId: 'c1' }), + ); + + expect(state.loading['c1']).toBe(true); + }); + + it('stores loaded settings', () => { + const state = workspaceConfigReducer( + initialWorkspaceConfigState, + loadWorkspaceConfigurationOverridesSuccess({ + clientId: 'c1', + settings: [{ settingKey: 'gitToken', envVarName: 'GIT_TOKEN', source: 'unset', hasOverride: false }], + }), + ); + + expect(state.settingsByClient['c1']).toHaveLength(1); + }); + + it('upserts returned setting', () => { + const state = workspaceConfigReducer( + { + ...initialWorkspaceConfigState, + settingsByClient: { + c1: [{ settingKey: 'gitToken', envVarName: 'GIT_TOKEN', source: 'unset', hasOverride: false }], + }, + }, + upsertWorkspaceConfigurationOverrideSuccess({ + clientId: 'c1', + setting: { + settingKey: 'gitToken', + envVarName: 'GIT_TOKEN', + source: 'override', + hasOverride: true, + value: 'abc', + }, + }), + ); + + expect(state.settingsByClient['c1'][0].source).toBe('override'); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.ts new file mode 100644 index 00000000..bc2d0f5a --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.reducer.ts @@ -0,0 +1,123 @@ +import { createReducer, on } from '@ngrx/store'; + +import { + clearWorkspaceConfigurationOverrides, + deleteWorkspaceConfigurationOverride, + deleteWorkspaceConfigurationOverrideFailure, + deleteWorkspaceConfigurationOverrideSuccess, + loadWorkspaceConfigurationOverrides, + loadWorkspaceConfigurationOverridesFailure, + loadWorkspaceConfigurationOverridesSuccess, + upsertWorkspaceConfigurationOverride, + upsertWorkspaceConfigurationOverrideFailure, + upsertWorkspaceConfigurationOverrideSuccess, +} from './workspace-config.actions'; +import type { WorkspaceConfigurationSettingResponseDto } from './workspace-config.types'; + +export interface WorkspaceConfigState { + settingsByClient: Record; + loading: Record; + saving: Record; + deleting: Record; + errors: Record; +} + +export const initialWorkspaceConfigState: WorkspaceConfigState = { + settingsByClient: {}, + loading: {}, + saving: {}, + deleting: {}, + errors: {}, +}; + +function getSettingKey(clientId: string, settingKey: string): string { + return `${clientId}:${settingKey}`; +} + +export const workspaceConfigReducer = createReducer( + initialWorkspaceConfigState, + on(loadWorkspaceConfigurationOverrides, (state, { clientId }) => ({ + ...state, + loading: { ...state.loading, [clientId]: true }, + errors: { ...state.errors, [clientId]: null }, + })), + on(loadWorkspaceConfigurationOverridesSuccess, (state, { clientId, settings }) => ({ + ...state, + settingsByClient: { ...state.settingsByClient, [clientId]: settings }, + loading: { ...state.loading, [clientId]: false }, + errors: { ...state.errors, [clientId]: null }, + })), + on(loadWorkspaceConfigurationOverridesFailure, (state, { clientId, error }) => ({ + ...state, + loading: { ...state.loading, [clientId]: false }, + errors: { ...state.errors, [clientId]: error }, + })), + on(upsertWorkspaceConfigurationOverride, (state, { clientId, settingKey }) => { + const key = getSettingKey(clientId, settingKey); + + return { + ...state, + saving: { ...state.saving, [key]: true }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(upsertWorkspaceConfigurationOverrideSuccess, (state, { clientId, setting }) => { + const key = getSettingKey(clientId, setting.settingKey); + const existing = state.settingsByClient[clientId] || []; + const hasExisting = existing.some((entry) => entry.settingKey === setting.settingKey); + const merged = hasExisting + ? existing.map((entry) => (entry.settingKey === setting.settingKey ? setting : entry)) + : [...existing, setting]; + + return { + ...state, + settingsByClient: { ...state.settingsByClient, [clientId]: merged }, + saving: { ...state.saving, [key]: false }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(upsertWorkspaceConfigurationOverrideFailure, (state, { clientId, settingKey, error }) => { + const key = getSettingKey(clientId, settingKey); + + return { + ...state, + saving: { ...state.saving, [key]: false }, + errors: { ...state.errors, [key]: error }, + }; + }), + on(deleteWorkspaceConfigurationOverride, (state, { clientId, settingKey }) => { + const key = getSettingKey(clientId, settingKey); + + return { + ...state, + deleting: { ...state.deleting, [key]: true }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(deleteWorkspaceConfigurationOverrideSuccess, (state, { clientId, settingKey }) => { + const key = getSettingKey(clientId, settingKey); + + return { + ...state, + deleting: { ...state.deleting, [key]: false }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(deleteWorkspaceConfigurationOverrideFailure, (state, { clientId, settingKey, error }) => { + const key = getSettingKey(clientId, settingKey); + + return { + ...state, + deleting: { ...state.deleting, [key]: false }, + errors: { ...state.errors, [key]: error }, + }; + }), + on(clearWorkspaceConfigurationOverrides, (state, { clientId }) => { + const { [clientId]: _, ...settingsByClient } = state.settingsByClient; + + return { + ...state, + settingsByClient, + }; + }), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.selectors.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.selectors.ts new file mode 100644 index 00000000..452efcba --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.selectors.ts @@ -0,0 +1,44 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import type { WorkspaceConfigState } from './workspace-config.reducer'; +import type { WorkspaceConfigurationSettingKey } from './workspace-config.types'; + +export const selectWorkspaceConfigState = createFeatureSelector('workspaceConfig'); + +export const selectWorkspaceConfigSettingsByClient = createSelector( + selectWorkspaceConfigState, + (state) => state.settingsByClient, +); +export const selectWorkspaceConfigLoading = createSelector(selectWorkspaceConfigState, (state) => state.loading); +export const selectWorkspaceConfigSaving = createSelector(selectWorkspaceConfigState, (state) => state.saving); +export const selectWorkspaceConfigDeleting = createSelector(selectWorkspaceConfigState, (state) => state.deleting); +export const selectWorkspaceConfigErrors = createSelector(selectWorkspaceConfigState, (state) => state.errors); + +function getSettingKey(clientId: string, settingKey: WorkspaceConfigurationSettingKey): string { + return `${clientId}:${settingKey}`; +} + +export const selectWorkspaceConfigurationSettings = (clientId: string) => + createSelector(selectWorkspaceConfigSettingsByClient, (settingsByClient) => settingsByClient[clientId] ?? []); + +export const selectWorkspaceConfigurationLoading = (clientId: string) => + createSelector(selectWorkspaceConfigLoading, (loading) => loading[clientId] ?? false); + +export const selectWorkspaceConfigurationError = (clientId: string) => + createSelector(selectWorkspaceConfigErrors, (errors) => errors[clientId] ?? null); + +export const selectWorkspaceConfigurationSettingSaving = ( + clientId: string, + settingKey: WorkspaceConfigurationSettingKey, +) => createSelector(selectWorkspaceConfigSaving, (saving) => saving[getSettingKey(clientId, settingKey)] ?? false); + +export const selectWorkspaceConfigurationSettingDeleting = ( + clientId: string, + settingKey: WorkspaceConfigurationSettingKey, +) => + createSelector(selectWorkspaceConfigDeleting, (deleting) => deleting[getSettingKey(clientId, settingKey)] ?? false); + +export const selectWorkspaceConfigurationSettingError = ( + clientId: string, + settingKey: WorkspaceConfigurationSettingKey, +) => createSelector(selectWorkspaceConfigErrors, (errors) => errors[getSettingKey(clientId, settingKey)] ?? null); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts new file mode 100644 index 00000000..e9fdf3be --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.types.ts @@ -0,0 +1,22 @@ +export type WorkspaceConfigurationSettingKey = + | 'gitRepositoryUrl' + | 'gitUsername' + | 'gitToken' + | 'gitPassword' + | 'gitPrivateKey' + | 'cursorApiKey' + | 'agentDefaultImage'; + +export type WorkspaceConfigurationValueSource = 'override' | 'default_env' | 'unset'; + +export interface WorkspaceConfigurationSettingResponseDto { + settingKey: WorkspaceConfigurationSettingKey; + envVarName: string; + value?: string; + source: WorkspaceConfigurationValueSource; + hasOverride: boolean; +} + +export interface UpsertWorkspaceConfigurationOverrideDto { + value: string; +} diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts index 12570c91..5f3b8af3 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -162,6 +162,12 @@ import { VcsFacade, vcsReducer, writeFile$, + WorkspaceConfigFacade, + workspaceConfigReducer, + loadWorkspaceConfigurationOverrides$, + upsertWorkspaceConfigurationOverride$, + deleteWorkspaceConfigurationOverride$, + reloadWorkspaceConfigurationAfterMutation$, } from '@forepath/framework/frontend/data-access-agent-console'; import { adminGuard, authGuard, identityAuthProviders, identityAuthRoutes } from '@forepath/identity/frontend'; import { provideEffects } from '@ngrx/effects'; @@ -288,6 +294,7 @@ export const agentConsoleRoutes: Route[] = [ KnowledgeFacade, ClientAgentAutonomyFacade, FilterRulesFacade, + WorkspaceConfigFacade, // Feature states - registered at feature level for lazy loading provideState('clients', clientsReducer), provideState('agents', agentsReducer), @@ -305,6 +312,7 @@ export const agentConsoleRoutes: Route[] = [ provideState('knowledge', knowledgeReducer), provideState('clientAgentAutonomy', clientAgentAutonomyReducer), provideState('filterRules', filterRulesReducer), + provideState('workspaceConfig', workspaceConfigReducer), // Effects - only active when this feature route is loaded provideEffects({ loadClients$, @@ -437,6 +445,10 @@ export const agentConsoleRoutes: Route[] = [ createFilterRule$, updateFilterRule$, deleteFilterRule$, + loadWorkspaceConfigurationOverrides$, + upsertWorkspaceConfigurationOverride$, + deleteWorkspaceConfigurationOverride$, + reloadWorkspaceConfigurationAfterMutation$, }), provideMonacoEditor(), ], diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html index 9b68d691..fc065732 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -218,6 +218,15 @@
Workspaces
@if (client.canManageWorkspaceConfiguration) { + + + + + + + - @let cannotSave = - (getWorkspaceConfigurationSettingSaving$(setting.settingKey) | async) === true; - @let cannotDelete = - !setting.hasOverride || + @let isSaving = (getWorkspaceConfigurationSettingSaving$(setting.settingKey) | async) === true; + @let isDeleting = (getWorkspaceConfigurationSettingDeleting$(setting.settingKey) | async) === true; + @let saveDisabled = + mutationInProgress || getEditingWorkspaceConfigurationValue(setting).trim().length === 0; + @let deleteDisabled = mutationInProgress || !setting.hasOverride;
- @if (!cannotSave) { - - } - @if (!cannotDelete) { - - } + +
@@ -3066,6 +3067,24 @@
Workspace C } + +
+ + Changing the environment variables will re-create the agent's container. Context may be lost. +
+ + diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts index a09e836a..73bc4f01 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -1057,6 +1057,15 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return this.workspaceConfigFacade.getError$(clientId); }), ); + readonly workspaceConfigurationMutating$: Observable = this.managingWorkspaceConfigurationClientId$.pipe( + switchMap((clientId) => { + if (!clientId) { + return of(false); + } + + return this.workspaceConfigFacade.isMutationInProgress$(clientId); + }), + ); // Client users modal state (for managing workspace users) readonly managingClientUsersClientId = signal(null);