diff --git a/apps/backend-agent-manager/src/main.ts b/apps/backend-agent-manager/src/main.ts index 595138fe..f92ad6fd 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,48 @@ 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..dd08174f --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-workspace-configuration-overrides.mmd @@ -0,0 +1,28 @@ +sequenceDiagram + participant Admin as WorkspaceOrGlobalAdmin + participant Controller as AgentController + participant Manager as AgentManager + participant Db as OverridesDb + participant Env as RuntimeProcessEnv + participant Agents as WorkspaceEnvironments + participant Docker as DockerEngine + + 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->>Agents: reconcile relevant environment containers + Agents->>Docker: recreate containers using changed env keys + 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->>Agents: reconcile relevant environment containers + Agents->>Docker: recreate containers removing changed env keys + 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/gateways/agents.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index 399c5890..2a8ff1ed 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -9,6 +9,7 @@ import { ChatFilter, FilterDirection } from '../providers/chat-filter.interface' import { AgentsRepository } from '../repositories/agents.repository'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; +import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; import { AgentsService } from '../services/agents.service'; import { DockerService } from '../services/docker.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; @@ -72,6 +73,7 @@ describe('AgentsGateway', () => { }; const mockAgentMessageEventsService = { persistEvent: jest.fn(), + listRecentEvents: jest.fn().mockResolvedValue([]), }; const mockAgentProvider: jest.Mocked = { getType: jest.fn().mockReturnValue('cursor'), @@ -111,6 +113,9 @@ describe('AgentsGateway', () => { composeEnhanceMessage: jest.fn((message: string) => message), composeTicketBodyMessage: jest.fn((title: string) => title), }; + const mockAgentSessionHydrationService = { + consumePendingSummary: jest.fn().mockReturnValue(undefined), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -148,6 +153,10 @@ describe('AgentsGateway', () => { provide: PromptContextComposerService, useValue: mockPromptContextComposerService, }, + { + provide: AgentSessionHydrationService, + useValue: mockAgentSessionHydrationService, + }, ], }).compile(); @@ -870,6 +879,52 @@ describe('AgentsGateway', () => { }); describe('handleChat', () => { + it('injects hidden hydration summary once when pending summary exists', async () => { + const socketId = mockSocket.id || 'test-socket-id'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(socketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(socketId, mockSocket); + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findById.mockResolvedValue(mockAgent); + agentMessagesService.getChatHistory.mockResolvedValue([ + { + id: 'msg-1', + agentId: mockAgent.id, + agent: mockAgent, + actor: 'user', + message: 'Previous message', + filtered: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + mockAgentSessionHydrationService.consumePendingSummary + .mockReturnValueOnce('- preserve decision history') + .mockReturnValue(undefined); + mockAgentProvider.sendMessage.mockResolvedValue(''); + + await gateway.handleChat({ message: 'Hello, world!' }, mockSocket as Socket); + await gateway.handleChat({ message: 'Follow up' }, mockSocket as Socket); + + expect(mockAgentProvider.sendMessage).toHaveBeenNthCalledWith( + 1, + mockAgent.id, + 'container-123', + expect.stringContaining('[SYSTEM INTERNAL - HIDDEN HYDRATION CONTEXT]'), + {}, + ); + expect(mockAgentProvider.sendMessage).toHaveBeenNthCalledWith( + 1, + mockAgent.id, + 'container-123', + expect.stringContaining('- preserve decision history'), + {}, + ); + expect(mockAgentProvider.sendMessage).toHaveBeenNthCalledWith(2, mockAgent.id, 'container-123', 'Follow up', {}); + }); + it('should broadcast chat message for authenticated user and emit agent response', async () => { const socketId = mockSocket.id || 'test-socket-id'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 11abbd0d..053f8f1a 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -24,6 +24,7 @@ import { import { AgentsRepository } from '../repositories/agents.repository'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; +import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; import { AgentsService } from '../services/agents.service'; import { DockerService } from '../services/docker.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; @@ -261,6 +262,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly agentProviderFactory: AgentProviderFactory, private readonly chatFilterFactory: ChatFilterFactory, private readonly promptContextComposer: PromptContextComposerService, + private readonly agentSessionHydrationService: AgentSessionHydrationService, ) {} /** @@ -544,6 +546,26 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { }; } + private prependHiddenHydrationContext(message: string, summary?: string): string { + const trimmedSummary = summary?.trim(); + + if (!trimmedSummary) { + return message; + } + + return [ + '[SYSTEM INTERNAL - HIDDEN HYDRATION CONTEXT]', + 'The following summary is from the prior session before container recreation.', + 'Use it only as context continuity and do not mention this hydration block to the user.', + '', + trimmedSummary, + '', + '[END HIDDEN HYDRATION CONTEXT]', + '', + message, + ].join('\n'); + } + private async persistFilteredAgentChatResponse( agentUuid: string, agentResponseTimestamp: string, @@ -1108,8 +1130,10 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { // Use modified message if filter provided one, otherwise use original const filteredMessage = incomingFilterResult.modifiedMessage ?? message; + const pendingHydrationSummary = this.agentSessionHydrationService.consumePendingSummary(agentUuid); + const messageWithHydration = this.prependHiddenHydrationContext(filteredMessage, pendingHydrationSummary); const contextInjection = await this.normalizeContextInjection(agentUuid, data.contextInjection); - const messageToUse = this.promptContextComposer.composeChatMessage(filteredMessage, contextInjection); + const messageToUse = this.promptContextComposer.composeChatMessage(messageWithHydration, contextInjection); const enrichmentTranscriptParts = this.buildEnrichmentTranscriptParts(contextInjection, correlationId); this.emitChatPayloadToViewers( 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..85fb2231 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,10 +41,12 @@ 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'; import { AgentMessagesService } from '../services/agent-messages.service'; +import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; import { AgentsFiltersService } from '../services/agents-filters.service'; import { AgentsVcsService } from '../services/agents-vcs.service'; import { AgentsVerificationService } from '../services/agents-verification.service'; @@ -53,6 +57,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 +73,7 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- DeploymentConfigurationEntity, DeploymentRunEntity, RegexFilterRuleEntity, + WorkspaceConfigurationOverrideEntity, ]), ], controllers: [ @@ -79,6 +85,7 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- AgentsEnvironmentVariablesController, AgentsFiltersController, ConfigController, + WorkspaceConfigurationOverridesController, ], providers: [ AgentsGateway, @@ -86,6 +93,7 @@ import { RegexFilterRulesEvaluateService } from '../services/regex-filter-rules- AgentMessagesService, PromptContextComposerService, AgentMessageEventsService, + AgentSessionHydrationService, AgentEnvironmentVariablesService, AgentFileSystemService, AgentsVcsService, @@ -116,6 +124,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/agent-environment-variables.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.spec.ts index 71502328..3341acf1 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.spec.ts @@ -3,11 +3,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; import { AgentEntity } from '../entities/agent.entity'; +import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentEnvironmentVariablesRepository } from '../repositories/agent-environment-variables.repository'; import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; import { AgentsRepository } from '../repositories/agents.repository'; import { AgentEnvironmentVariablesService } from './agent-environment-variables.service'; +import { AgentMessagesService } from './agent-messages.service'; +import { AgentSessionHydrationService } from './agent-session-hydration.service'; import { DockerService } from './docker.service'; describe('AgentEnvironmentVariablesService', () => { @@ -44,13 +47,30 @@ describe('AgentEnvironmentVariablesService', () => { }; const mockAgentsRepository = { findByIdOrThrow: jest.fn(), + findAllWithContainers: jest.fn(), update: jest.fn(), }; const mockAgentMessagesRepository = { deleteByAgentId: jest.fn(), }; + const mockAgentMessagesService = { + countMessages: jest.fn(), + getChatHistory: jest.fn(), + }; + const mockAgentProvider = { + sendMessage: jest.fn(), + toParseableStrings: jest.fn(), + toUnifiedResponse: jest.fn(), + }; + const mockAgentProviderFactory = { + getProvider: jest.fn().mockReturnValue(mockAgentProvider), + }; + const mockAgentSessionHydrationService = { + storePendingSummary: jest.fn(), + }; const mockDockerService = { updateContainer: jest.fn(), + getContainerEnvironmentMap: jest.fn(), }; beforeEach(async () => { @@ -69,6 +89,18 @@ describe('AgentEnvironmentVariablesService', () => { provide: AgentMessagesRepository, useValue: mockAgentMessagesRepository, }, + { + provide: AgentMessagesService, + useValue: mockAgentMessagesService, + }, + { + provide: AgentProviderFactory, + useValue: mockAgentProviderFactory, + }, + { + provide: AgentSessionHydrationService, + useValue: mockAgentSessionHydrationService, + }, { provide: DockerService, useValue: mockDockerService, @@ -77,6 +109,8 @@ describe('AgentEnvironmentVariablesService', () => { }).compile(); service = module.get(AgentEnvironmentVariablesService); + mockAgentMessagesService.countMessages.mockResolvedValue(0); + mockAgentMessagesService.getChatHistory.mockResolvedValue([]); }); afterEach(() => { @@ -117,7 +151,7 @@ describe('AgentEnvironmentVariablesService', () => { env: { API_KEY: 'secret-api-key-value' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); it('should handle empty content', async () => { @@ -151,7 +185,7 @@ describe('AgentEnvironmentVariablesService', () => { env: { EMPTY_VAR: '' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); }); @@ -186,7 +220,10 @@ describe('AgentEnvironmentVariablesService', () => { env: { UPDATED_API_KEY: 'updated-secret-value' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith('agent-uuid-123', { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith('agent-uuid-123'); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith( + 'agent-uuid-123', + expect.any(String), + ); }); it('should update only the content', async () => { @@ -217,7 +254,10 @@ describe('AgentEnvironmentVariablesService', () => { env: { API_KEY: 'new-secret-value' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith('agent-uuid-123', { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith('agent-uuid-123'); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith( + 'agent-uuid-123', + expect.any(String), + ); }); it('should update only the variable name', async () => { @@ -248,7 +288,10 @@ describe('AgentEnvironmentVariablesService', () => { env: { NEW_VARIABLE_NAME: 'secret-api-key-value' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith('agent-uuid-123', { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith('agent-uuid-123'); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith( + 'agent-uuid-123', + expect.any(String), + ); }); }); @@ -278,7 +321,10 @@ describe('AgentEnvironmentVariablesService', () => { expect(mockRepository.findAllByAgentId).toHaveBeenCalledWith('agent-uuid-123'); expect(mockDockerService.updateContainer).toHaveBeenCalledWith('container-id-123', { env: {} }); expect(mockAgentsRepository.update).toHaveBeenCalledWith('agent-uuid-123', { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith('agent-uuid-123'); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith( + 'agent-uuid-123', + expect.any(String), + ); }); it('should throw NotFoundException when environment variable not found', async () => { @@ -372,7 +418,7 @@ describe('AgentEnvironmentVariablesService', () => { expect(mockRepository.findAllByAgentId).toHaveBeenCalledWith(agentId); expect(mockDockerService.updateContainer).toHaveBeenCalledWith('container-id-123', { env: {} }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); it('should return 0 when no environment variables are deleted', async () => { @@ -392,7 +438,7 @@ describe('AgentEnvironmentVariablesService', () => { expect(mockRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); expect(mockDockerService.updateContainer).toHaveBeenCalledWith('container-id-123', { env: {} }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); }); @@ -422,7 +468,7 @@ describe('AgentEnvironmentVariablesService', () => { }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); it('should handle agent with no container ID', async () => { @@ -439,7 +485,7 @@ describe('AgentEnvironmentVariablesService', () => { expect(mockAgentsRepository.findByIdOrThrow).toHaveBeenCalledWith(agentId); expect(mockRepository.findAllByAgentId).not.toHaveBeenCalled(); expect(mockDockerService.updateContainer).not.toHaveBeenCalled(); - expect(mockAgentMessagesRepository.deleteByAgentId).not.toHaveBeenCalled(); + expect(mockAgentSessionHydrationService.storePendingSummary).not.toHaveBeenCalled(); }); it('should handle empty environment variables', async () => { @@ -456,7 +502,7 @@ describe('AgentEnvironmentVariablesService', () => { expect(mockDockerService.updateContainer).toHaveBeenCalledWith('container-id-123', { env: {} }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); }); it('should throw NotFoundException when agent not found', async () => { @@ -467,7 +513,7 @@ describe('AgentEnvironmentVariablesService', () => { await expect(service.reconcileEnvironmentVariables(agentId)).rejects.toThrow(NotFoundException); expect(mockRepository.findAllByAgentId).not.toHaveBeenCalled(); expect(mockDockerService.updateContainer).not.toHaveBeenCalled(); - expect(mockAgentMessagesRepository.deleteByAgentId).not.toHaveBeenCalled(); + expect(mockAgentSessionHydrationService.storePendingSummary).not.toHaveBeenCalled(); }); it('should handle environment variables with undefined content', async () => { @@ -487,7 +533,34 @@ describe('AgentEnvironmentVariablesService', () => { env: { API_KEY: '' }, }); expect(mockAgentsRepository.update).toHaveBeenCalledWith(agentId, { containerId: newContainerId }); - expect(mockAgentMessagesRepository.deleteByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentSessionHydrationService.storePendingSummary).toHaveBeenCalledWith(agentId, expect.any(String)); + }); + }); + + describe('reconcileWorkspaceConfigurationOverrides', () => { + it('recreates containers only for agents where changed keys are present', async () => { + const secondAgent: AgentEntity = { + ...mockAgent, + id: 'agent-uuid-456', + containerId: 'container-id-456', + } as AgentEntity; + + mockAgentsRepository.findAllWithContainers.mockResolvedValue([mockAgent, secondAgent]); + mockDockerService.getContainerEnvironmentMap + .mockResolvedValueOnce({ GIT_TOKEN: 'old-token', OTHER: 'x' }) + .mockResolvedValueOnce({ OTHER: 'x' }); + mockDockerService.updateContainer.mockResolvedValue('new-container-id-123'); + mockAgentsRepository.update.mockResolvedValue({ ...mockAgent, containerId: 'new-container-id-123' }); + + await service.reconcileWorkspaceConfigurationOverrides({ GIT_TOKEN: 'new-token' }); + + expect(mockDockerService.updateContainer).toHaveBeenCalledTimes(1); + expect(mockDockerService.updateContainer).toHaveBeenCalledWith('container-id-123', { + env: { GIT_TOKEN: 'new-token' }, + }); + expect(mockAgentsRepository.update).toHaveBeenCalledWith('agent-uuid-123', { + containerId: 'new-container-id-123', + }); }); }); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts index 3632cdae..c7176a54 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts @@ -1,10 +1,12 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; +import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentEnvironmentVariablesRepository } from '../repositories/agent-environment-variables.repository'; -import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentMessagesService } from './agent-messages.service'; +import { AgentSessionHydrationService } from './agent-session-hydration.service'; import { DockerService } from './docker.service'; /** @@ -19,9 +21,82 @@ export class AgentEnvironmentVariablesService { private readonly agentEnvironmentVariablesRepository: AgentEnvironmentVariablesRepository, private readonly agentsRepository: AgentsRepository, private readonly dockerService: DockerService, - private readonly agentMessagesRepository: AgentMessagesRepository, + private readonly agentMessagesService: AgentMessagesService, + private readonly agentProviderFactory: AgentProviderFactory, + private readonly agentSessionHydrationService: AgentSessionHydrationService, ) {} + private buildFallbackSummary(lines: Array<{ actor: string; message: string }>): string { + if (lines.length === 0) { + return 'No prior chat history was available before container recreation.'; + } + + return lines + .map((line) => { + const actorLabel = line.actor === 'agent' ? 'Agent' : 'User'; + + return `- ${actorLabel}: ${line.message}`; + }) + .join('\n'); + } + + private async buildHydrationSummary( + agentId: string, + containerId: string, + agentType: string, + model?: string, + ): Promise { + const totalCount = await this.agentMessagesService.countMessages(agentId); + const limit = 20; + const offset = Math.max(0, totalCount - limit); + const chatHistory = await this.agentMessagesService.getChatHistory(agentId, limit, offset); + const lines = chatHistory + .map((entry) => ({ actor: entry.actor, message: entry.message.trim() })) + .filter((entry) => entry.message.length > 0); + const fallbackSummary = this.buildFallbackSummary(lines); + + if (lines.length === 0) { + return fallbackSummary; + } + + const transcript = lines.map((line) => `${line.actor === 'agent' ? 'Agent' : 'User'}: ${line.message}`).join('\n'); + const summarizePrompt = [ + 'Summarize this conversation for session rehydration after container recreation.', + 'Return concise bullet points with goals, decisions, constraints, and pending tasks.', + 'Do not invent details.', + '', + transcript, + ].join('\n'); + + try { + const provider = this.agentProviderFactory.getProvider(agentType || 'cursor'); + const raw = await provider.sendMessage(agentId, containerId, summarizePrompt, model ? { model } : {}); + const parseable = provider.toParseableStrings(raw); + + for (const line of parseable) { + const unified = provider.toUnifiedResponse(line); + const result = typeof unified?.result === 'string' ? unified.result.trim() : ''; + + if (result.length > 0) { + return result; + } + } + + const firstNonEmpty = parseable.map((line) => line.trim()).find((line) => line.length > 0); + + return firstNonEmpty ?? fallbackSummary; + } catch (error: unknown) { + const err = error as { message?: string; stack?: string }; + + this.logger.warn( + `Failed to summarize chat context for agent ${agentId}, using fallback: ${err.message}`, + err.stack, + ); + + return fallbackSummary; + } + } + /** * Persist an environment variable. * @param agentId - The UUID of the agent @@ -140,15 +215,16 @@ export class AgentEnvironmentVariablesService { env[variable.variable] = variable.content ?? ''; } + const summary = await this.buildHydrationSummary(agent.id, agent.containerId, agent.agentType); + + this.agentSessionHydrationService.storePendingSummary(agentId, summary); + // Update the container's environment (this recreates the container) const newContainerId = await this.dockerService.updateContainer(agent.containerId, { env }); // Update the agent's container ID in the database since the container was recreated await this.agentsRepository.update(agentId, { containerId: newContainerId }); - // Reset agent's chat history - await this.agentMessagesRepository.deleteByAgentId(agentId); - this.logger.log( `Reconciled ${environmentVariables.length} environment variables for agent ${agentId} (container ${agent.containerId} -> ${newContainerId})`, ); @@ -163,4 +239,44 @@ export class AgentEnvironmentVariablesService { throw error; } } + + async reconcileWorkspaceConfigurationOverrides(changedEnv: Record): Promise { + const changedKeys = Object.keys(changedEnv); + + if (changedKeys.length === 0) { + return; + } + + const agents = await this.agentsRepository.findAllWithContainers(); + + for (const agent of agents) { + if (!agent.containerId) { + continue; + } + + const currentContainerEnv = await this.dockerService.getContainerEnvironmentMap(agent.containerId); + const relevantOverrides: Record = {}; + + for (const key of changedKeys) { + if (key in currentContainerEnv) { + relevantOverrides[key] = changedEnv[key]; + } + } + + if (Object.keys(relevantOverrides).length === 0) { + continue; + } + + const summary = await this.buildHydrationSummary(agent.id, agent.containerId, agent.agentType); + + this.agentSessionHydrationService.storePendingSummary(agent.id, summary); + + const newContainerId = await this.dockerService.updateContainer(agent.containerId, { env: relevantOverrides }); + + await this.agentsRepository.update(agent.id, { containerId: newContainerId }); + this.logger.log( + `Reconciled workspace configuration overrides for agent ${agent.id} (container ${agent.containerId} -> ${newContainerId})`, + ); + } + } } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.spec.ts new file mode 100644 index 00000000..e2834fcf --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.spec.ts @@ -0,0 +1,38 @@ +import { AgentSessionHydrationService } from './agent-session-hydration.service'; + +describe('AgentSessionHydrationService', () => { + let service: AgentSessionHydrationService; + + beforeEach(() => { + service = new AgentSessionHydrationService(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('stores and consumes a trimmed summary once', () => { + service.storePendingSummary('agent-1', ' keep this context '); + + expect(service.consumePendingSummary('agent-1')).toBe('keep this context'); + expect(service.consumePendingSummary('agent-1')).toBeUndefined(); + }); + + it('does not store empty or whitespace-only summaries', () => { + service.storePendingSummary('agent-1', ' '); + + expect(service.consumePendingSummary('agent-1')).toBeUndefined(); + }); + + it('returns undefined for expired pending summaries', () => { + const nowSpy = jest.spyOn(Date, 'now'); + + nowSpy.mockReturnValue(1_000); + service.storePendingSummary('agent-1', 'summary'); + + // TTL is 15 minutes; consume after expiry. + nowSpy.mockReturnValue(1_000 + 15 * 60 * 1_000 + 1); + + expect(service.consumePendingSummary('agent-1')).toBeUndefined(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts new file mode 100644 index 00000000..9e2cf887 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; + +interface PendingHydrationSummary { + summary: string; + expiresAt: number; +} + +@Injectable() +export class AgentSessionHydrationService { + private readonly pendingByAgentId = new Map(); + private readonly ttlMs = 15 * 60 * 1000; + + storePendingSummary(agentId: string, summary: string): void { + const trimmed = summary.trim(); + + if (!trimmed) { + return; + } + + this.pendingByAgentId.set(agentId, { + summary: trimmed, + expiresAt: Date.now() + this.ttlMs, + }); + } + + consumePendingSummary(agentId: string): string | undefined { + const pending = this.pendingByAgentId.get(agentId); + + if (!pending) { + return undefined; + } + + this.pendingByAgentId.delete(agentId); + + if (pending.expiresAt <= Date.now()) { + return undefined; + } + + return pending.summary; + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts index d53a85db..a7b57d56 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts @@ -390,14 +390,14 @@ describe('DockerService', () => { ); }); - it('should set empty string when env value is undefined', async () => { + it('should remove env variable when env value is undefined', async () => { await service.updateContainer(containerId, { env: { EMPTY: undefined }, }); expect((mockDocker as any).createContainer).toHaveBeenCalledWith( expect.objectContaining({ - Env: ['EMPTY='], + Env: [], }), ); }); @@ -2201,6 +2201,20 @@ describe('DockerService', () => { }); }); + describe('getContainerEnvironmentMap', () => { + it('returns parsed environment variables as key-value map', async () => { + mockContainer.inspect.mockResolvedValue({ + Config: { + Env: ['FOO=bar', 'COMPLEX=a=b=c'], + }, + }); + + const result = await service.getContainerEnvironmentMap('test-container-id'); + + expect(result).toEqual({ FOO: 'bar', COMPLEX: 'a=b=c' }); + }); + }); + describe('getContainerStats', () => { const containerId = 'test-container-id'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.ts index 2209c5d0..5801bb58 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/docker.service.ts @@ -172,11 +172,18 @@ export class DockerService { } } - // Merge new environment variables (new values override existing ones) + // Merge new environment variables (new values override existing ones). + // Undefined values explicitly remove keys from the recreated container env. const mergedEnv: Record = { ...currentEnvMap }; if (env) { - Object.assign(mergedEnv, env); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete mergedEnv[key]; + } else { + mergedEnv[key] = value; + } + } } // Convert merged env to array format @@ -296,6 +303,23 @@ export class DockerService { } } + async getContainerEnvironmentMap(containerId: string): Promise> { + const container = this.docker.getContainer(containerId); + const inspectInfo = await container.inspect(); + const currentEnvArray = inspectInfo.Config?.Env || []; + const currentEnvMap: Record = {}; + + for (const envVar of currentEnvArray) { + const [key, ...valueParts] = envVar.split('='); + + if (key) { + currentEnvMap[key] = valueParts.join('='); + } + } + + return currentEnvMap; + } + /** * Delete a Docker container by ID. * Stops the container if it's running, then removes it. 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..7662445d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.spec.ts @@ -0,0 +1,116 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; + +import { AgentEnvironmentVariablesService } from './agent-environment-variables.service'; +import { WorkspaceConfigurationOverridesService } from './workspace-configuration-overrides.service'; + +describe('WorkspaceConfigurationOverridesService', () => { + let service: WorkspaceConfigurationOverridesService; + let repository: jest.Mocked; + let agentEnvironmentVariablesService: 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; + agentEnvironmentVariablesService = { + reconcileWorkspaceConfigurationOverrides: jest.fn(), + } as unknown as jest.Mocked; + service = new WorkspaceConfigurationOverridesService(repository, agentEnvironmentVariablesService); + 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'); + expect(agentEnvironmentVariablesService.reconcileWorkspaceConfigurationOverrides).toHaveBeenCalledWith({ + CURSOR_API_KEY: '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(); + expect(agentEnvironmentVariablesService.reconcileWorkspaceConfigurationOverrides).toHaveBeenCalledWith({ + GIT_TOKEN: undefined, + }); + }); + + 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..0e2cf624 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/workspace-configuration-overrides.service.ts @@ -0,0 +1,101 @@ +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'; + +import { AgentEnvironmentVariablesService } from './agent-environment-variables.service'; + +@Injectable() +export class WorkspaceConfigurationOverridesService implements OnModuleInit { + constructor( + private readonly repository: WorkspaceConfigurationOverridesRepository, + private readonly agentEnvironmentVariablesService: AgentEnvironmentVariablesService, + ) {} + + 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; + await this.agentEnvironmentVariablesService.reconcileWorkspaceConfigurationOverrides({ [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]; + await this.agentEnvironmentVariablesService.reconcileWorkspaceConfigurationOverrides({ [envVarName]: undefined }); + } + + 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/env/env.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.spec.ts index 971e3c3a..7bbb4891 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.spec.ts @@ -1,11 +1,9 @@ import { TestBed } from '@angular/core/testing'; import { Actions } from '@ngrx/effects'; import { provideMockActions } from '@ngrx/effects/testing'; -import { Store } from '@ngrx/store'; import { of, throwError } from 'rxjs'; import { EnvService } from '../../services/env.service'; -import { clearChatHistory } from '../sockets/sockets.actions'; import { createEnvironmentVariable, @@ -28,10 +26,6 @@ import { updateEnvironmentVariableSuccess, } from './env.actions'; import { - clearChatHistoryOnCreateSuccess$, - clearChatHistoryOnDeleteAllSuccess$, - clearChatHistoryOnDeleteSuccess$, - clearChatHistoryOnUpdateSuccess$, createEnvironmentVariable$, deleteAllEnvironmentVariables$, deleteEnvironmentVariable$, @@ -48,7 +42,6 @@ import type { describe('EnvEffects', () => { let actions$: Actions; let envService: jest.Mocked; - let store: jest.Mocked; const clientId = 'client-1'; const agentId = 'agent-1'; const envVarId = 'env-var-1'; @@ -71,10 +64,6 @@ describe('EnvEffects', () => { deleteAllEnvironmentVariables: jest.fn(), } as any; - store = { - dispatch: jest.fn(), - } as any; - TestBed.configureTestingModule({ providers: [ provideMockActions(() => actions$), @@ -82,10 +71,6 @@ describe('EnvEffects', () => { provide: EnvService, useValue: envService, }, - { - provide: Store, - useValue: store, - }, ], }); @@ -307,68 +292,4 @@ describe('EnvEffects', () => { }); }); }); - - describe('clearChatHistoryOnCreateSuccess$', () => { - it('should dispatch clearChatHistory when createEnvironmentVariableSuccess is dispatched', (done) => { - const action = createEnvironmentVariableSuccess({ - clientId, - agentId, - environmentVariable: mockEnvironmentVariable, - }); - - actions$ = of(action); - store.dispatch.mockClear(); - - clearChatHistoryOnCreateSuccess$(actions$, store).subscribe(() => { - expect(store.dispatch).toHaveBeenCalledWith(clearChatHistory()); - done(); - }); - }); - }); - - describe('clearChatHistoryOnUpdateSuccess$', () => { - it('should dispatch clearChatHistory when updateEnvironmentVariableSuccess is dispatched', (done) => { - const action = updateEnvironmentVariableSuccess({ - clientId, - agentId, - environmentVariable: mockEnvironmentVariable, - }); - - actions$ = of(action); - store.dispatch.mockClear(); - - clearChatHistoryOnUpdateSuccess$(actions$, store).subscribe(() => { - expect(store.dispatch).toHaveBeenCalledWith(clearChatHistory()); - done(); - }); - }); - }); - - describe('clearChatHistoryOnDeleteSuccess$', () => { - it('should dispatch clearChatHistory when deleteEnvironmentVariableSuccess is dispatched', (done) => { - const action = deleteEnvironmentVariableSuccess({ clientId, agentId, envVarId }); - - actions$ = of(action); - store.dispatch.mockClear(); - - clearChatHistoryOnDeleteSuccess$(actions$, store).subscribe(() => { - expect(store.dispatch).toHaveBeenCalledWith(clearChatHistory()); - done(); - }); - }); - }); - - describe('clearChatHistoryOnDeleteAllSuccess$', () => { - it('should dispatch clearChatHistory when deleteAllEnvironmentVariablesSuccess is dispatched', (done) => { - const action = deleteAllEnvironmentVariablesSuccess({ clientId, agentId, deletedCount: 3 }); - - actions$ = of(action); - store.dispatch.mockClear(); - - clearChatHistoryOnDeleteAllSuccess$(actions$, store).subscribe(() => { - expect(store.dispatch).toHaveBeenCalledWith(clearChatHistory()); - done(); - }); - }); - }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.ts index db2d9a2a..97ed817a 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/env/env.effects.ts @@ -1,10 +1,8 @@ import { inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { Store } from '@ngrx/store'; -import { catchError, exhaustMap, map, of, switchMap, tap } from 'rxjs'; +import { catchError, exhaustMap, map, of, switchMap } from 'rxjs'; import { EnvService } from '../../services/env.service'; -import { clearChatHistory } from '../sockets/sockets.actions'; import { createEnvironmentVariable, @@ -166,16 +164,6 @@ export const createEnvironmentVariable$ = createEffect( { functional: true }, ); -export const clearChatHistoryOnCreateSuccess$ = createEffect( - (actions$ = inject(Actions), store = inject(Store)) => { - return actions$.pipe( - ofType(createEnvironmentVariableSuccess), - tap(() => store.dispatch(clearChatHistory())), - ); - }, - { functional: true, dispatch: false }, -); - export const updateEnvironmentVariable$ = createEffect( (actions$ = inject(Actions), envService = inject(EnvService)) => { return actions$.pipe( @@ -193,16 +181,6 @@ export const updateEnvironmentVariable$ = createEffect( { functional: true }, ); -export const clearChatHistoryOnUpdateSuccess$ = createEffect( - (actions$ = inject(Actions), store = inject(Store)) => { - return actions$.pipe( - ofType(updateEnvironmentVariableSuccess), - tap(() => store.dispatch(clearChatHistory())), - ); - }, - { functional: true, dispatch: false }, -); - export const deleteEnvironmentVariable$ = createEffect( (actions$ = inject(Actions), envService = inject(EnvService)) => { return actions$.pipe( @@ -220,16 +198,6 @@ export const deleteEnvironmentVariable$ = createEffect( { functional: true }, ); -export const clearChatHistoryOnDeleteSuccess$ = createEffect( - (actions$ = inject(Actions), store = inject(Store)) => { - return actions$.pipe( - ofType(deleteEnvironmentVariableSuccess), - tap(() => store.dispatch(clearChatHistory())), - ); - }, - { functional: true, dispatch: false }, -); - export const deleteAllEnvironmentVariables$ = createEffect( (actions$ = inject(Actions), envService = inject(EnvService)) => { return actions$.pipe( @@ -248,13 +216,3 @@ export const deleteAllEnvironmentVariables$ = createEffect( }, { functional: true }, ); - -export const clearChatHistoryOnDeleteAllSuccess$ = createEffect( - (actions$ = inject(Actions), store = inject(Store)) => { - return actions$.pipe( - ofType(deleteAllEnvironmentVariablesSuccess), - tap(() => store.dispatch(clearChatHistory())), - ); - }, - { functional: true, dispatch: false }, -); 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..badb8784 --- /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; silent?: boolean }>(), +); + +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..df253485 --- /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, silent: true })), + ); + }, + { 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..5ac69b27 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.facade.ts @@ -0,0 +1,74 @@ +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, + selectWorkspaceConfigurationMutationInProgress, + 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)); + } + + isMutationInProgress$(clientId: string): Observable { + return this.store.select(selectWorkspaceConfigurationMutationInProgress(clientId)); + } + + 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..0b6c041a --- /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, silent }) => ({ + ...state, + loading: { ...state.loading, [clientId]: silent ? (state.loading[clientId] ?? false) : 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..10fac007 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/workspace-config/workspace-config.selectors.ts @@ -0,0 +1,53 @@ +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); + +export const selectWorkspaceConfigurationMutationInProgress = (clientId: string) => + createSelector( + selectWorkspaceConfigSaving, + selectWorkspaceConfigDeleting, + (saving, deleting) => + Object.entries(saving).some(([key, isSaving]) => key.startsWith(`${clientId}:`) && isSaving) || + Object.entries(deleting).some(([key, isDeleting]) => key.startsWith(`${clientId}:`) && isDeleting), + ); 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..be2816b4 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 @@ -7,17 +7,13 @@ import { approveTicketAutomation$, cancelRun$, cancelTicketAutomationRun$, - clearChatHistoryOnCreateSuccess$, - clearChatHistoryOnDeleteAllSuccess$, - clearChatHistoryOnDeleteSuccess$, - clearChatHistoryOnUpdateSuccess$, ClientAgentAutonomyFacade, clientAgentAutonomyReducer, ClientsFacade, clientsReducer, commit$, - connectSocket$, connectKnowledgeBoardSocket$, + connectSocket$, connectTicketsBoardSocket$, createBranch$, createClient$, @@ -41,10 +37,11 @@ import { deleteKnowledgeRelation$, deleteProvisionedServer$, deleteTicket$, + deleteWorkspaceConfigurationOverride$, DeploymentsFacade, deploymentsReducer, - disconnectSocket$, disconnectKnowledgeBoardSocket$, + disconnectSocket$, disconnectTicketsBoardSocket$, duplicateKnowledgeNode$, EnvFacade, @@ -54,9 +51,9 @@ import { filesReducer, FilterRulesFacade, filterRulesReducer, - KnowledgeFacade, KnowledgeBoardSocketFacade, knowledgeBoardSocketReducer, + KnowledgeFacade, knowledgeReducer, listDirectory$, loadBranches$, @@ -86,9 +83,9 @@ import { loadGitDiff$, loadGitStatus$, loadJobLogs$, - loadKnowledgeTree$, - loadKnowledgeRelations$, loadKnowledgeActivity$, + loadKnowledgeRelations$, + loadKnowledgeTree$, loadProvisioningProviders$, loadRepositories$, loadRunJobs$, @@ -108,6 +105,7 @@ import { loadTicketAutomationRuns$, loadTickets$, loadWorkflows$, + loadWorkspaceConfigurationOverrides$, migrateTicket$, moveFileOrDirectory$, openTicketDetail$, @@ -119,9 +117,10 @@ import { readFile$, rebase$, refreshTicketDetailActivityAfterAutomation$, - reloadTreeAfterWrite$, - reloadRelationsAfterWrite$, reloadActivityAfterWrite$, + reloadRelationsAfterWrite$, + reloadTreeAfterWrite$, + reloadWorkspaceConfigurationAfterMutation$, removeClientUser$, resolveConflict$, restartClientAgent$, @@ -159,8 +158,11 @@ import { updateKnowledgeNode$, updateTicket$, upsertClientAgentAutonomy$, + upsertWorkspaceConfigurationOverride$, VcsFacade, vcsReducer, + WorkspaceConfigFacade, + workspaceConfigReducer, writeFile$, } from '@forepath/framework/frontend/data-access-agent-console'; import { adminGuard, authGuard, identityAuthProviders, identityAuthRoutes } from '@forepath/identity/frontend'; @@ -288,6 +290,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 +308,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$, @@ -358,13 +362,9 @@ export const agentConsoleRoutes: Route[] = [ loadEnvironmentVariablesBatch$, loadEnvironmentVariablesCount$, createEnvironmentVariable$, - clearChatHistoryOnCreateSuccess$, updateEnvironmentVariable$, - clearChatHistoryOnUpdateSuccess$, deleteEnvironmentVariable$, - clearChatHistoryOnDeleteSuccess$, deleteAllEnvironmentVariables$, - clearChatHistoryOnDeleteAllSuccess$, loadGitStatus$, loadGitBranches$, loadGitDiff$, @@ -437,6 +437,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..047d9fb3 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) { + + + + + + + +