diff --git a/apps/backend-agent-manager/project.json b/apps/backend-agent-manager/project.json index fb4bd8b4..e44d1a69 100644 --- a/apps/backend-agent-manager/project.json +++ b/apps/backend-agent-manager/project.json @@ -213,7 +213,13 @@ "defaultConfiguration": "test" }, "start-containers": { - "dependsOn": ["api-container-image"], + "dependsOn": [ + "api-container-image", + "worker-container-image", + "vnc-container-image", + "ssh-container-image", + "agi-container-image" + ], "cache": false, "executor": "nx:run-commands", "options": { diff --git a/apps/frontend-agent-console/src/i18n/messages.de.xlf b/apps/frontend-agent-console/src/i18n/messages.de.xlf index d88c23cc..4327a913 100644 --- a/apps/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.de.xlf @@ -438,6 +438,22 @@ Open Editor Editor öffnen + + Open agent config + Agentenkonfiguration öffnen + + + Open provider agent config files (requires workspace management access) + Provider-Konfigurationsdateien öffnen (erfordert Verwaltungszugriff auf den Arbeitsbereich) + + + Open agent config in New Window + Agentenkonfiguration in neuem Fenster öffnen + + + Agent config + Agentenkonfiguration + Open Virtual Desktop Virtuellen Desktop öffnen diff --git a/apps/frontend-agent-console/src/i18n/messages.xlf b/apps/frontend-agent-console/src/i18n/messages.xlf index 56d43437..d978dc8f 100644 --- a/apps/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/frontend-agent-console/src/i18n/messages.xlf @@ -299,6 +299,18 @@ Open Editor + + Open agent config + + + Open provider agent config files (requires workspace management access) + + + Open agent config in New Window + + + Agent config + Open Virtual Desktop diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/overview.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/overview.mmd index e9cadb00..729a44cb 100644 --- a/libs/domains/framework/backend/feature-agent-controller/docs/overview.mmd +++ b/libs/domains/framework/backend/feature-agent-controller/docs/overview.mmd @@ -30,10 +30,10 @@ flowchart TB subgraph FILES["📁 HTTP REST API - Proxied File System (/api/clients/:id/agents/:agentId/files)"] direction TB - FILES_USE["Use Case: File System Operations via Client
Proxy to Remote Agent-Manager
Request-Response Pattern"] - FILES_USE --> FILES1["📖 Read File (Proxied)
GET /api/clients/:id/agents/:agentId/files/*path
Returns: File Content (base64)"] - FILES_USE --> FILES2["✍️ Write File (Proxied)
PUT /api/clients/:id/agents/:agentId/files/*path
Returns: 204 No Content"] - FILES_USE --> FILES3["📂 List Directory (Proxied)
GET /api/clients/:id/agents/:agentId/files?path=
Returns: File Nodes"] + FILES_USE["Use Case: File System Operations via Client
Proxy to Remote Agent-Manager
Optional query context=app|config (config needs workspace management)"] + FILES_USE --> FILES1["📖 Read File (Proxied)
GET /api/clients/:id/agents/:agentId/files/*path?context=
Returns: File Content (base64)"] + FILES_USE --> FILES2["✍️ Write File (Proxied)
PUT /api/clients/:id/agents/:agentId/files/*path?context=
Returns: 204 No Content"] + FILES_USE --> FILES3["📂 List Directory (Proxied)
GET /api/clients/:id/agents/:agentId/files?path=&context=
Returns: File Nodes"] FILES_USE --> FILES4["➕ Create File/Dir (Proxied)
POST /api/clients/:id/agents/:agentId/files/*path
Returns: 201 Created"] FILES_USE --> FILES5["🗑️ Delete File/Dir (Proxied)
DELETE /api/clients/:id/agents/:agentId/files/*path
Returns: 204 No Content"] end diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-files.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-files.mmd index fea38bcf..a645b0cd 100644 --- a/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-files.mmd +++ b/libs/domains/framework/backend/feature-agent-controller/docs/sequence-http-files.mmd @@ -11,8 +11,9 @@ sequenceDiagram rect rgb(230, 240, 255) Note over Client,Remote: Read File (Proxied) - Client->>API: GET /api/clients/{id}/agents/{agentId}/files/{path} - API->>FileProxy: readFile(clientId, agentId, filePath) + Client->>API: GET /api/clients/{id}/agents/{agentId}/files/{path}?context=app|config + API->>API: authorizeFileProxyRequest
(ensureClientAccess or
ensureWorkspaceManagementAccess if config) + API->>FileProxy: readFile(clientId, agentId, filePath, context) FileProxy->>Repo: findByIdOrThrow(clientId) Repo-->>FileProxy: client entity alt Authentication Type: API_KEY @@ -25,7 +26,7 @@ sequenceDiagram FileProxy->>FileProxy: getAuthHeader(clientId)
Returns: Bearer {token} end FileProxy->>FileProxy: buildAgentFilesApiUrl(endpoint, agentId) - FileProxy->>Remote: GET {endpoint}/api/agents/{agentId}/files/{path}
Authorization: Bearer {token/apiKey} + FileProxy->>Remote: GET .../files/{path}?context=...
Authorization: Bearer {token/apiKey} Remote-->>FileProxy: 200 OK
{content: base64, encoding: 'utf-8'|'base64'} FileProxy-->>API: FileContentDto API-->>Client: 200 OK
{file content} @@ -33,8 +34,8 @@ sequenceDiagram rect rgb(240, 255, 240) Note over Client,Remote: Write File (Proxied) - Client->>API: PUT /api/clients/{id}/agents/{agentId}/files/{path}
{content: base64, encoding?} - API->>FileProxy: writeFile(clientId, agentId, filePath, writeFileDto) + Client->>API: PUT /api/clients/{id}/agents/{agentId}/files/{path}?context=app|config
{content: base64, encoding?} + API->>FileProxy: writeFile(clientId, agentId, filePath, writeFileDto, context) FileProxy->>Repo: findByIdOrThrow(clientId) Repo-->>FileProxy: client entity FileProxy->>FileProxy: getAuthHeader(clientId) 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 4154f84c..8d490f38 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -1265,7 +1265,8 @@ paths: name: path schema: type: string - description: Directory path relative to /app (defaults to '.') + description: Directory path relative to the selected context root (defaults to '.') + - $ref: '#/components/parameters/fileManagerContext' responses: '200': description: Array of file nodes @@ -1275,8 +1276,10 @@ paths: type: array items: $ref: '#/components/schemas/FileNodeDto' + '400': + description: Invalid context or agent type does not support configuration file access (proxied from agent-manager) '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client, agent, or directory not found /clients/{id}/agents/{agentId}/files/{path}: @@ -1303,7 +1306,8 @@ paths: required: true schema: type: string - description: File path relative to /app (supports nested paths) + description: File path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' responses: '200': description: File content (base64-encoded) @@ -1312,9 +1316,9 @@ paths: schema: $ref: '#/components/schemas/FileContentDto' '400': - description: Invalid path + description: Invalid path, invalid context, or agent type does not support configuration file access '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client, agent, or file not found put: @@ -1340,7 +1344,8 @@ paths: required: true schema: type: string - description: File path relative to /app (supports nested paths) + description: File path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -1351,9 +1356,9 @@ paths: '204': description: File written successfully '400': - description: Invalid path or content too large + description: Invalid path, content too large, invalid context, or agent type does not support configuration file access '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client or agent not found delete: @@ -1379,14 +1384,15 @@ paths: required: true schema: type: string - description: File or directory path relative to /app (supports nested paths) + description: File or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' responses: '204': description: File or directory deleted successfully '400': - description: Invalid path + description: Invalid path, invalid context, or agent type does not support configuration file access '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client, agent, or file/directory not found patch: @@ -1412,7 +1418,8 @@ paths: required: true schema: type: string - description: Source file or directory path relative to /app (supports nested paths) + description: Source file or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -1423,9 +1430,9 @@ paths: '204': description: File or directory moved successfully '400': - description: Invalid path or destination + description: Invalid path, destination, invalid context, or agent type does not support configuration file access '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client, agent, or source file/directory not found post: @@ -1451,7 +1458,8 @@ paths: required: true schema: type: string - description: File or directory path relative to /app (supports nested paths) + description: File or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -1462,9 +1470,9 @@ paths: '201': description: File or directory created '400': - description: Invalid request + description: Invalid request, invalid context, or agent type does not support configuration file access '403': - description: User does not have access to this client + description: User does not have access to this client, or lacks workspace management rights when context is config '404': description: Client or agent not found /clients/{id}/agents/{agentId}/environment: @@ -3304,6 +3312,18 @@ components: type: http scheme: bearer bearerFormat: JWT + parameters: + fileManagerContext: + name: context + in: query + required: false + schema: + type: string + enum: [app, config] + default: app + description: | + Proxied to agent-manager. `app` (default) is the workspace root; `config` is the provider configuration directory when supported. + When `config` is set, the caller must have workspace management rights (same as other workspace configuration APIs). schemas: AuthenticationType: type: string diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index d539a13b..da17a348 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -503,10 +503,40 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.readFile.mockResolvedValue(mockFileContent); - const result = await controller.readFile('client-uuid', 'agent-uuid', 'test.txt', mockReq); + const result = await controller.readFile('client-uuid', 'agent-uuid', 'test.txt', undefined, mockReq); expect(result).toEqual(mockFileContent); - expect(fileSystemProxyService.readFile).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'test.txt'); + expect(fileSystemProxyService.readFile).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'test.txt', 'app'); + }); + + it('should use workspace management access and forward config context', async () => { + const mockFileContent: FileContentDto = { + content: Buffer.from('{}', 'utf-8').toString('base64'), + encoding: 'utf-8', + }; + const mockReq = { apiKeyAuthenticated: true } as any; + clientsRepository.findById.mockResolvedValue({ id: 'client-uuid', userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + fileSystemProxyService.readFile.mockResolvedValue(mockFileContent); + + await controller.readFile('client-uuid', 'agent-uuid', 'cfg.json', 'config', mockReq); + + expect(fileSystemProxyService.readFile).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'cfg.json', 'config'); + }); + + it('should reject config context when user cannot manage workspace configuration', async () => { + const mockReq = { apiKeyAuthenticated: false, user: { id: 'user-1', roles: ['user'] } } as any; + clientsRepository.findById.mockResolvedValue({ id: 'client-uuid', userId: 'other-user' } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue({ + id: 'rel-1', + clientId: 'client-uuid', + userId: 'user-1', + role: ClientUserRole.USER, + } as any); + + await expect(controller.readFile('client-uuid', 'agent-uuid', 'cfg.json', 'config', mockReq)).rejects.toThrow( + WORKSPACE_MANAGEMENT_FORBIDDEN_MESSAGE, + ); }); }); @@ -521,9 +551,15 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.writeFile.mockResolvedValue(undefined); - await controller.writeFile('client-uuid', 'agent-uuid', 'test.txt', writeDto, mockReq); + await controller.writeFile('client-uuid', 'agent-uuid', 'test.txt', writeDto, undefined, mockReq); - expect(fileSystemProxyService.writeFile).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'test.txt', writeDto); + expect(fileSystemProxyService.writeFile).toHaveBeenCalledWith( + 'client-uuid', + 'agent-uuid', + 'test.txt', + writeDto, + 'app', + ); }); }); @@ -543,10 +579,10 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.listDirectory.mockResolvedValue(mockFileNodes); - const result = await controller.listDirectory('client-uuid', 'agent-uuid', 'test-dir', mockReq); + const result = await controller.listDirectory('client-uuid', 'agent-uuid', 'test-dir', undefined, mockReq); expect(result).toEqual(mockFileNodes); - expect(fileSystemProxyService.listDirectory).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'test-dir'); + expect(fileSystemProxyService.listDirectory).toHaveBeenCalledWith('client-uuid', 'agent-uuid', 'test-dir', 'app'); }); it('should use default path when not provided', async () => { @@ -556,9 +592,9 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.listDirectory.mockResolvedValue(mockFileNodes); - await controller.listDirectory('client-uuid', 'agent-uuid', undefined, mockReq); + await controller.listDirectory('client-uuid', 'agent-uuid', undefined, undefined, mockReq); - expect(fileSystemProxyService.listDirectory).toHaveBeenCalledWith('client-uuid', 'agent-uuid', '.'); + expect(fileSystemProxyService.listDirectory).toHaveBeenCalledWith('client-uuid', 'agent-uuid', '.', 'app'); }); }); @@ -573,13 +609,21 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.createFileOrDirectory.mockResolvedValue(undefined); - await controller.createFileOrDirectory('client-uuid', 'agent-uuid', 'new-file.txt', createDto, mockReq); + await controller.createFileOrDirectory( + 'client-uuid', + 'agent-uuid', + 'new-file.txt', + createDto, + undefined, + mockReq, + ); expect(fileSystemProxyService.createFileOrDirectory).toHaveBeenCalledWith( 'client-uuid', 'agent-uuid', 'new-file.txt', createDto, + 'app', ); }); @@ -592,13 +636,14 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.createFileOrDirectory.mockResolvedValue(undefined); - await controller.createFileOrDirectory('client-uuid', 'agent-uuid', 'new-dir', createDto, mockReq); + await controller.createFileOrDirectory('client-uuid', 'agent-uuid', 'new-dir', createDto, undefined, mockReq); expect(fileSystemProxyService.createFileOrDirectory).toHaveBeenCalledWith( 'client-uuid', 'agent-uuid', 'new-dir', createDto, + 'app', ); }); @@ -617,6 +662,7 @@ describe('ClientsController', () => { 'agent-uuid', ['nested', 'path', 'file.txt'], createDto, + undefined, mockReq, ); @@ -625,6 +671,7 @@ describe('ClientsController', () => { 'agent-uuid', 'nested/path/file.txt', createDto, + 'app', ); }); @@ -638,7 +685,7 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); await expect( - controller.createFileOrDirectory('client-uuid', 'agent-uuid', undefined, createDto, mockReq), + controller.createFileOrDirectory('client-uuid', 'agent-uuid', undefined, createDto, undefined, mockReq), ).rejects.toThrow('File path is required'); }); @@ -652,7 +699,14 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); await expect( - controller.createFileOrDirectory('client-uuid', 'agent-uuid', { invalid: 'path' }, createDto, mockReq), + controller.createFileOrDirectory( + 'client-uuid', + 'agent-uuid', + { invalid: 'path' }, + createDto, + undefined, + mockReq, + ), ).rejects.toThrow('File path must be a string or array, got object'); }); }); @@ -664,12 +718,13 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.deleteFileOrDirectory.mockResolvedValue(undefined); - await controller.deleteFileOrDirectory('client-uuid', 'agent-uuid', 'file-to-delete.txt', mockReq); + await controller.deleteFileOrDirectory('client-uuid', 'agent-uuid', 'file-to-delete.txt', undefined, mockReq); expect(fileSystemProxyService.deleteFileOrDirectory).toHaveBeenCalledWith( 'client-uuid', 'agent-uuid', 'file-to-delete.txt', + 'app', ); }); }); @@ -684,13 +739,14 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); fileSystemProxyService.moveFileOrDirectory.mockResolvedValue(undefined); - await controller.moveFileOrDirectory('client-uuid', 'agent-uuid', 'source-file.txt', moveDto, mockReq); + await controller.moveFileOrDirectory('client-uuid', 'agent-uuid', 'source-file.txt', moveDto, undefined, mockReq); expect(fileSystemProxyService.moveFileOrDirectory).toHaveBeenCalledWith( 'client-uuid', 'agent-uuid', 'source-file.txt', moveDto, + 'app', ); }); @@ -708,6 +764,7 @@ describe('ClientsController', () => { 'agent-uuid', ['nested', 'path', 'file.txt'], moveDto, + undefined, mockReq, ); @@ -716,6 +773,7 @@ describe('ClientsController', () => { 'agent-uuid', 'nested/path/file.txt', moveDto, + 'app', ); }); @@ -728,7 +786,7 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); await expect( - controller.moveFileOrDirectory('client-uuid', 'agent-uuid', undefined, moveDto, mockReq), + controller.moveFileOrDirectory('client-uuid', 'agent-uuid', undefined, moveDto, undefined, mockReq), ).rejects.toThrow('File path is required'); }); @@ -741,7 +799,7 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); await expect( - controller.moveFileOrDirectory('client-uuid', 'agent-uuid', { invalid: 'path' }, moveDto, mockReq), + controller.moveFileOrDirectory('client-uuid', 'agent-uuid', { invalid: 'path' }, moveDto, undefined, mockReq), ).rejects.toThrow('File path must be a string or array, got object'); }); @@ -754,7 +812,7 @@ describe('ClientsController', () => { clientUsersRepository.findUserClientAccess.mockResolvedValue(null); await expect( - controller.moveFileOrDirectory('client-uuid', 'agent-uuid', 'source.txt', moveDto, mockReq), + controller.moveFileOrDirectory('client-uuid', 'agent-uuid', 'source.txt', moveDto, undefined, mockReq), ).rejects.toThrow('Destination path is required'); }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 2d0073dd..17a496a5 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -9,6 +9,8 @@ import { FileContentDto, FileNodeDto, MoveFileDto, + parseAgentFileManagerContext, + type AgentFileManagerContext, UpdateAgentDto, UpdateEnvironmentVariableDto, WriteFileDto, @@ -395,6 +397,23 @@ export class ClientsController { } } + /** + * Authorize proxied file API access. `context=config` requires workspace management rights. + */ + private async authorizeFileProxyRequest( + clientId: string, + contextRaw: string | undefined, + req?: RequestWithUser, + ): Promise { + const context = parseAgentFileManagerContext(contextRaw); + if (context === 'config') { + await ensureWorkspaceManagementAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + } else { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + } + return context; + } + /** * Read file content from agent container via client proxy. * Only accessible if the user has access to the client. @@ -409,9 +428,10 @@ export class ClientsController { @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string; if (typeof path === 'string') { @@ -424,7 +444,7 @@ export class ClientsController { } else { normalizedPath = '.'; } - return await this.clientAgentFileSystemProxyService.readFile(id, agentId, normalizedPath); + return await this.clientAgentFileSystemProxyService.readFile(id, agentId, normalizedPath, context); } /** @@ -443,9 +463,10 @@ export class ClientsController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() writeFileDto: WriteFileDto, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -459,7 +480,7 @@ export class ClientsController { if (!normalizedPath) { throw new BadRequestException('File path is required'); } - await this.clientAgentFileSystemProxyService.writeFile(id, agentId, normalizedPath, writeFileDto); + await this.clientAgentFileSystemProxyService.writeFile(id, agentId, normalizedPath, writeFileDto, context); } /** @@ -476,10 +497,11 @@ export class ClientsController { @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Query('path') path?: string, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); - return await this.clientAgentFileSystemProxyService.listDirectory(id, agentId, path || '.'); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); + return await this.clientAgentFileSystemProxyService.listDirectory(id, agentId, path || '.', context); } /** @@ -498,9 +520,10 @@ export class ClientsController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() createFileDto: CreateFileDto, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -514,7 +537,13 @@ export class ClientsController { if (!normalizedPath) { throw new BadRequestException('File path is required'); } - await this.clientAgentFileSystemProxyService.createFileOrDirectory(id, agentId, normalizedPath, createFileDto); + await this.clientAgentFileSystemProxyService.createFileOrDirectory( + id, + agentId, + normalizedPath, + createFileDto, + context, + ); } /** @@ -531,9 +560,10 @@ export class ClientsController { @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -547,7 +577,7 @@ export class ClientsController { if (!normalizedPath) { throw new BadRequestException('File path is required'); } - await this.clientAgentFileSystemProxyService.deleteFileOrDirectory(id, agentId, normalizedPath); + await this.clientAgentFileSystemProxyService.deleteFileOrDirectory(id, agentId, normalizedPath, context); } /** @@ -566,9 +596,10 @@ export class ClientsController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() moveFileDto: MoveFileDto, + @Query('context') contextRaw?: string, @Req() req?: RequestWithUser, ): Promise { - await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const context = await this.authorizeFileProxyRequest(id, contextRaw, req); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -585,7 +616,7 @@ export class ClientsController { if (!moveFileDto.destination) { throw new BadRequestException('Destination path is required'); } - await this.clientAgentFileSystemProxyService.moveFileOrDirectory(id, agentId, normalizedPath, moveFileDto); + await this.clientAgentFileSystemProxyService.moveFileOrDirectory(id, agentId, normalizedPath, moveFileDto, context); } /** diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.spec.ts index 690eb525..314d5c36 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.spec.ts @@ -107,6 +107,23 @@ describe('ClientAgentFileSystemProxyService', () => { headers: expect.objectContaining({ Authorization: 'Bearer test-api-key', }), + params: undefined, + }), + ); + }); + + it('should forward context=config on read', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: mockFileContent, + } as any); + + await service.readFile(mockClientId, mockAgentId, mockFilePath, 'config'); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + params: { context: 'config' }, }), ); }); @@ -133,6 +150,7 @@ describe('ClientAgentFileSystemProxyService', () => { headers: expect.objectContaining({ Authorization: 'Bearer keycloak-jwt-token', }), + params: undefined, }), ); }); @@ -185,6 +203,23 @@ describe('ClientAgentFileSystemProxyService', () => { method: 'PUT', url: expect.stringContaining(`/api/agents/${mockAgentId}/files`), data: writeDto, + params: undefined, + }), + ); + }); + + it('should forward context=config on write', async () => { + const writeDto: WriteFileDto = { + content: Buffer.from('x', 'utf-8').toString('base64'), + }; + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.writeFile(mockClientId, mockAgentId, mockFilePath, writeDto, 'config'); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + params: { context: 'config' }, }), ); }); @@ -209,6 +244,22 @@ describe('ClientAgentFileSystemProxyService', () => { ); }); + it('should forward context=config on list', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: mockFileNodes, + } as any); + + await service.listDirectory(mockClientId, mockAgentId, '.', 'config'); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + params: { path: '.', context: 'config' }, + }), + ); + }); + it('should use default path when not provided', async () => { clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); mockedAxios.request.mockResolvedValue({ diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts index 2382addf..af229b22 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-file-system-proxy.service.ts @@ -4,6 +4,7 @@ import { FileContentDto, FileNodeDto, MoveFileDto, + type AgentFileManagerContext, WriteFileDto, } from '@forepath/framework/backend/feature-agent-manager'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; @@ -146,11 +147,17 @@ export class ClientAgentFileSystemProxyService { * @param filePath - The relative path to the file (from /app) * @returns File content (base64-encoded) and encoding type */ - async readFile(clientId: string, agentId: string, filePath: string): Promise { + async readFile( + clientId: string, + agentId: string, + filePath: string, + context: AgentFileManagerContext = 'app', + ): Promise { const encodedPath = encodeURIComponent(filePath); return await this.makeRequest(clientId, agentId, { method: 'GET', url: `/${encodedPath}`, + params: context === 'config' ? { context: 'config' } : undefined, }); } @@ -161,12 +168,19 @@ export class ClientAgentFileSystemProxyService { * @param filePath - The relative path to the file (from /app) * @param writeFileDto - The file content to write (base64-encoded) */ - async writeFile(clientId: string, agentId: string, filePath: string, writeFileDto: WriteFileDto): Promise { + async writeFile( + clientId: string, + agentId: string, + filePath: string, + writeFileDto: WriteFileDto, + context: AgentFileManagerContext = 'app', + ): Promise { const encodedPath = encodeURIComponent(filePath); await this.makeRequest(clientId, agentId, { method: 'PUT', url: `/${encodedPath}`, data: writeFileDto, + params: context === 'config' ? { context: 'config' } : undefined, }); } @@ -177,10 +191,22 @@ export class ClientAgentFileSystemProxyService { * @param path - Optional directory path (defaults to '.') * @returns Array of file nodes */ - async listDirectory(clientId: string, agentId: string, path?: string): Promise { + async listDirectory( + clientId: string, + agentId: string, + path?: string, + context: AgentFileManagerContext = 'app', + ): Promise { + const params: Record = {}; + if (path) { + params.path = path; + } + if (context === 'config') { + params.context = 'config'; + } return await this.makeRequest(clientId, agentId, { method: 'GET', - params: path ? { path } : undefined, + params: Object.keys(params).length ? params : undefined, }); } @@ -196,12 +222,14 @@ export class ClientAgentFileSystemProxyService { agentId: string, filePath: string, createFileDto: CreateFileDto, + context: AgentFileManagerContext = 'app', ): Promise { const encodedPath = encodeURIComponent(filePath); await this.makeRequest(clientId, agentId, { method: 'POST', url: `/${encodedPath}`, data: createFileDto, + params: context === 'config' ? { context: 'config' } : undefined, }); } @@ -211,11 +239,17 @@ export class ClientAgentFileSystemProxyService { * @param agentId - The UUID of the agent * @param filePath - The relative path to delete (from /app) */ - async deleteFileOrDirectory(clientId: string, agentId: string, filePath: string): Promise { + async deleteFileOrDirectory( + clientId: string, + agentId: string, + filePath: string, + context: AgentFileManagerContext = 'app', + ): Promise { const encodedPath = encodeURIComponent(filePath); await this.makeRequest(clientId, agentId, { method: 'DELETE', url: `/${encodedPath}`, + params: context === 'config' ? { context: 'config' } : undefined, }); } @@ -231,12 +265,14 @@ export class ClientAgentFileSystemProxyService { agentId: string, sourcePath: string, moveFileDto: MoveFileDto, + context: AgentFileManagerContext = 'app', ): Promise { const encodedPath = encodeURIComponent(sourcePath); await this.makeRequest(clientId, agentId, { method: 'PATCH', url: `/${encodedPath}`, data: moveFileDto, + params: context === 'config' ? { context: 'config' } : undefined, }); } } diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd index bdc607d2..d02f56c2 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/overview.mmd @@ -12,10 +12,10 @@ flowchart TB subgraph FILES["📁 HTTP REST API - File System (/api/agents/:id/files)"] direction TB - FILES_USE["Use Case: File System Operations
Read, Write, List, Create, Delete, Move
Container Files at /app"] - FILES_USE --> FILES1["📖 Read File
GET /api/agents/:id/files/*path
Returns: File Content"] - FILES_USE --> FILES2["✍️ Write File
PUT /api/agents/:id/files/*path
Returns: 204 No Content"] - FILES_USE --> FILES3["📂 List Directory
GET /api/agents/:id/files?path=
Returns: File Nodes"] + FILES_USE["Use Case: File System Operations
Read, Write, List, Create, Delete, Move
Optional query context=app|config (default app)"] + FILES_USE --> FILES1["📖 Read File
GET /api/agents/:id/files/*path?context=
Returns: File Content"] + FILES_USE --> FILES2["✍️ Write File
PUT /api/agents/:id/files/*path?context=
Returns: 204 No Content"] + FILES_USE --> FILES3["📂 List Directory
GET /api/agents/:id/files?path=&context=
Returns: File Nodes"] FILES_USE --> FILES4["➕ Create File/Dir
POST /api/agents/:id/files/*path
Returns: 201 Created"] FILES_USE --> FILES5["🗑️ Delete File/Dir
DELETE /api/agents/:id/files/*path
Returns: 204 No Content"] FILES_USE --> FILES6["📦 Move File/Dir
PATCH /api/agents/:id/files/*path
Returns: 204 No Content"] diff --git a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-files.mmd b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-files.mmd index 24055f95..46e59b07 100644 --- a/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-files.mmd +++ b/libs/domains/framework/backend/feature-agent-manager/docs/sequence-http-files.mmd @@ -10,8 +10,8 @@ sequenceDiagram rect rgb(230, 240, 255) Note over Client,Container: Read File - Client->>API: GET /api/agents/{id}/files/{path} - API->>FileService: readFile(agentId, filePath) + Client->>API: GET /api/agents/{id}/files/{path}?context=app|config + API->>FileService: readFile(agentId, filePath, context) FileService->>FileService: sanitizePath(filePath) FileService->>AgentService: findOne(agentId) AgentService-->>FileService: agent entity @@ -26,8 +26,8 @@ sequenceDiagram rect rgb(240, 255, 240) Note over Client,Container: Write File - Client->>API: PUT /api/agents/{id}/files/{path}
{content} - API->>FileService: writeFile(agentId, filePath, content) + Client->>API: PUT /api/agents/{id}/files/{path}?context=app|config
{content} + API->>FileService: writeFile(agentId, filePath, content, encoding, context) FileService->>FileService: sanitizePath(filePath) FileService->>FileService: validate content size FileService->>AgentService: findOne(agentId) @@ -43,8 +43,8 @@ sequenceDiagram rect rgb(255, 240, 240) Note over Client,Container: List Directory - Client->>API: GET /api/agents/{id}/files?path={dir} - API->>FileService: listDirectory(agentId, path) + Client->>API: GET /api/agents/{id}/files?path={dir}&context=app|config + API->>FileService: listDirectory(agentId, path, context) FileService->>FileService: sanitizePath(path) FileService->>AgentService: findOne(agentId) AgentService-->>FileService: agent entity 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 4f23a349..4d8d1dc5 100644 --- a/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-manager/spec/openapi.yaml @@ -227,7 +227,8 @@ paths: name: path schema: type: string - description: Directory path relative to /app (defaults to '.') + description: Directory path relative to the selected context root (defaults to '.') + - $ref: '#/components/parameters/fileManagerContext' responses: '200': description: Array of file nodes @@ -237,6 +238,8 @@ paths: type: array items: $ref: '#/components/schemas/FileNodeDto' + '400': + description: Invalid path, invalid context, or agent type does not support configuration file access '404': description: Agent or directory not found /agents/{agentId}/files/{path}: @@ -255,7 +258,8 @@ paths: required: true schema: type: string - description: File path relative to /app (supports nested paths) + description: File path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' responses: '200': description: File content @@ -264,7 +268,7 @@ paths: schema: $ref: '#/components/schemas/FileContentDto' '400': - description: Invalid path + description: Invalid path, invalid context, or agent type does not support configuration file access '404': description: Agent or file not found post: @@ -282,7 +286,8 @@ paths: required: true schema: type: string - description: File or directory path relative to /app (supports nested paths) + description: File or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -293,7 +298,7 @@ paths: '201': description: File or directory created '400': - description: Invalid request + description: Invalid request, invalid context, or agent type does not support configuration file access '404': description: Agent not found put: @@ -311,7 +316,8 @@ paths: required: true schema: type: string - description: File path relative to /app (supports nested paths) + description: File path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -322,7 +328,7 @@ paths: '204': description: File written successfully '400': - description: Invalid path or content too large + description: Invalid path, content too large, invalid context, or agent type does not support configuration file access '404': description: Agent not found delete: @@ -340,12 +346,13 @@ paths: required: true schema: type: string - description: File or directory path relative to /app (supports nested paths) + description: File or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' responses: '204': description: File or directory deleted successfully '400': - description: Invalid path + description: Invalid path, invalid context, or agent type does not support configuration file access '404': description: Agent or file/directory not found patch: @@ -363,7 +370,8 @@ paths: required: true schema: type: string - description: Source file or directory path relative to /app (supports nested paths) + description: Source file or directory path relative to the selected context root (supports nested paths) + - $ref: '#/components/parameters/fileManagerContext' requestBody: required: true content: @@ -374,7 +382,7 @@ paths: '204': description: File or directory moved successfully '400': - description: Invalid path or destination + description: Invalid path, destination, invalid context, or agent type does not support configuration file access '404': description: Agent or source file/directory not found /agents/{agentId}/environment: @@ -1267,6 +1275,18 @@ components: type: http scheme: bearer bearerFormat: JWT + parameters: + fileManagerContext: + name: context + in: query + required: false + schema: + type: string + enum: [app, config] + default: app + description: | + Filesystem root for relative paths. `app` (default) uses the provider workspace root (e.g. /app). + `config` uses the provider agent configuration directory when implemented (e.g. ~/.cursor, resolved in the container). schemas: CreateAgentDto: type: object 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 780b9489..719cfa0e 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/index.ts @@ -41,6 +41,7 @@ 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/utils/agent-file-manager-context'; // 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/controllers/agents-files.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.spec.ts index ce396a86..e7c91fc6 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { CreateFileDto } from '../dto/create-file.dto'; import { FileContentDto } from '../dto/file-content.dto'; @@ -67,10 +68,18 @@ describe('AgentsFilesController', () => { it('should return file content', async () => { service.readFile.mockResolvedValue(mockFileContent); - const result = await controller.readFile(mockAgentId, mockFilePath); + const result = await controller.readFile(mockAgentId, mockFilePath, undefined); expect(result).toEqual(mockFileContent); - expect(service.readFile).toHaveBeenCalledWith(mockAgentId, mockFilePath); + expect(service.readFile).toHaveBeenCalledWith(mockAgentId, mockFilePath, 'app'); + }); + + it('should forward config context to service', async () => { + service.readFile.mockResolvedValue(mockFileContent); + + await controller.readFile(mockAgentId, mockFilePath, 'config'); + + expect(service.readFile).toHaveBeenCalledWith(mockAgentId, mockFilePath, 'config'); }); }); @@ -82,9 +91,15 @@ describe('AgentsFilesController', () => { service.writeFile.mockResolvedValue(undefined); - await controller.writeFile(mockAgentId, mockFilePath, writeDto); + await controller.writeFile(mockAgentId, mockFilePath, writeDto, undefined); - expect(service.writeFile).toHaveBeenCalledWith(mockAgentId, mockFilePath, writeDto.content, writeDto.encoding); + expect(service.writeFile).toHaveBeenCalledWith( + mockAgentId, + mockFilePath, + writeDto.content, + writeDto.encoding, + 'app', + ); }); }); @@ -92,19 +107,23 @@ describe('AgentsFilesController', () => { it('should return directory contents', async () => { service.listDirectory.mockResolvedValue(mockFileNodes); - const result = await controller.listDirectory(mockAgentId, mockDirectoryPath); + const result = await controller.listDirectory(mockAgentId, mockDirectoryPath, undefined); expect(result).toEqual(mockFileNodes); - expect(service.listDirectory).toHaveBeenCalledWith(mockAgentId, mockDirectoryPath); + expect(service.listDirectory).toHaveBeenCalledWith(mockAgentId, mockDirectoryPath, 'app'); }); it('should use default path when not provided', async () => { service.listDirectory.mockResolvedValue(mockFileNodes); - const result = await controller.listDirectory(mockAgentId); + const result = await controller.listDirectory(mockAgentId, undefined, undefined); expect(result).toEqual(mockFileNodes); - expect(service.listDirectory).toHaveBeenCalledWith(mockAgentId, '.'); + expect(service.listDirectory).toHaveBeenCalledWith(mockAgentId, '.', 'app'); + }); + + it('should reject invalid context', async () => { + await expect(controller.listDirectory(mockAgentId, '.', 'workspace')).rejects.toThrow(BadRequestException); }); }); @@ -117,9 +136,15 @@ describe('AgentsFilesController', () => { service.createFileOrDirectory.mockResolvedValue(undefined); - await controller.createFileOrDirectory(mockAgentId, mockFilePath, createDto); + await controller.createFileOrDirectory(mockAgentId, mockFilePath, createDto, undefined); - expect(service.createFileOrDirectory).toHaveBeenCalledWith(mockAgentId, mockFilePath, 'file', 'File content'); + expect(service.createFileOrDirectory).toHaveBeenCalledWith( + mockAgentId, + mockFilePath, + 'file', + 'File content', + 'app', + ); }); it('should create directory', async () => { @@ -129,13 +154,14 @@ describe('AgentsFilesController', () => { service.createFileOrDirectory.mockResolvedValue(undefined); - await controller.createFileOrDirectory(mockAgentId, mockDirectoryPath, createDto); + await controller.createFileOrDirectory(mockAgentId, mockDirectoryPath, createDto, undefined); expect(service.createFileOrDirectory).toHaveBeenCalledWith( mockAgentId, mockDirectoryPath, 'directory', undefined, + 'app', ); }); @@ -147,13 +173,14 @@ describe('AgentsFilesController', () => { service.createFileOrDirectory.mockResolvedValue(undefined); - await controller.createFileOrDirectory(mockAgentId, ['nested', 'path', 'file.txt'], createDto); + await controller.createFileOrDirectory(mockAgentId, ['nested', 'path', 'file.txt'], createDto, undefined); expect(service.createFileOrDirectory).toHaveBeenCalledWith( mockAgentId, 'nested/path/file.txt', 'file', 'File content', + 'app', ); }); @@ -163,7 +190,7 @@ describe('AgentsFilesController', () => { content: 'File content', }; - await expect(controller.createFileOrDirectory(mockAgentId, undefined, createDto)).rejects.toThrow( + await expect(controller.createFileOrDirectory(mockAgentId, undefined, createDto, undefined)).rejects.toThrow( 'File path is required', ); }); @@ -174,9 +201,9 @@ describe('AgentsFilesController', () => { content: 'File content', }; - await expect(controller.createFileOrDirectory(mockAgentId, { invalid: 'path' }, createDto)).rejects.toThrow( - 'File path must be a string or array, got object', - ); + await expect( + controller.createFileOrDirectory(mockAgentId, { invalid: 'path' }, createDto, undefined), + ).rejects.toThrow('File path must be a string or array, got object'); }); }); @@ -184,9 +211,9 @@ describe('AgentsFilesController', () => { it('should delete file or directory', async () => { service.deleteFileOrDirectory.mockResolvedValue(undefined); - await controller.deleteFileOrDirectory(mockAgentId, mockFilePath); + await controller.deleteFileOrDirectory(mockAgentId, mockFilePath, undefined); - expect(service.deleteFileOrDirectory).toHaveBeenCalledWith(mockAgentId, mockFilePath); + expect(service.deleteFileOrDirectory).toHaveBeenCalledWith(mockAgentId, mockFilePath, 'app'); }); }); @@ -198,9 +225,9 @@ describe('AgentsFilesController', () => { service.moveFileOrDirectory.mockResolvedValue(undefined); - await controller.moveFileOrDirectory(mockAgentId, mockFilePath, moveDto); + await controller.moveFileOrDirectory(mockAgentId, mockFilePath, moveDto, undefined); - expect(service.moveFileOrDirectory).toHaveBeenCalledWith(mockAgentId, mockFilePath, moveDto.destination); + expect(service.moveFileOrDirectory).toHaveBeenCalledWith(mockAgentId, mockFilePath, moveDto.destination, 'app'); }); it('should handle array path parameter', async () => { @@ -210,12 +237,13 @@ describe('AgentsFilesController', () => { service.moveFileOrDirectory.mockResolvedValue(undefined); - await controller.moveFileOrDirectory(mockAgentId, ['nested', 'path', 'file.txt'], moveDto); + await controller.moveFileOrDirectory(mockAgentId, ['nested', 'path', 'file.txt'], moveDto, undefined); expect(service.moveFileOrDirectory).toHaveBeenCalledWith( mockAgentId, 'nested/path/file.txt', moveDto.destination, + 'app', ); }); @@ -224,7 +252,7 @@ describe('AgentsFilesController', () => { destination: 'new-location/file.txt', }; - await expect(controller.moveFileOrDirectory(mockAgentId, undefined, moveDto)).rejects.toThrow( + await expect(controller.moveFileOrDirectory(mockAgentId, undefined, moveDto, undefined)).rejects.toThrow( 'File path is required', ); }); @@ -234,9 +262,9 @@ describe('AgentsFilesController', () => { destination: 'new-location/file.txt', }; - await expect(controller.moveFileOrDirectory(mockAgentId, { invalid: 'path' }, moveDto)).rejects.toThrow( - 'File path must be a string or array, got object', - ); + await expect( + controller.moveFileOrDirectory(mockAgentId, { invalid: 'path' }, moveDto, undefined), + ).rejects.toThrow('File path must be a string or array, got object'); }); it('should throw BadRequestException when destination is missing', async () => { @@ -244,7 +272,7 @@ describe('AgentsFilesController', () => { destination: '', }; - await expect(controller.moveFileOrDirectory(mockAgentId, mockFilePath, moveDto)).rejects.toThrow( + await expect(controller.moveFileOrDirectory(mockAgentId, mockFilePath, moveDto, undefined)).rejects.toThrow( 'Destination path is required', ); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts index 97014b70..bffa7c08 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-files.controller.ts @@ -19,6 +19,7 @@ import { FileNodeDto } from '../dto/file-node.dto'; import { MoveFileDto } from '../dto/move-file.dto'; import { WriteFileDto } from '../dto/write-file.dto'; import { AgentFileSystemService } from '../services/agent-file-system.service'; +import { parseAgentFileManagerContext } from '../utils/agent-file-manager-context'; /** * Controller for agent file system operations. @@ -38,7 +39,9 @@ export class AgentsFilesController { async readFile( @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, + @Query('context') contextRaw?: string, ): Promise { + const context = parseAgentFileManagerContext(contextRaw); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string; if (typeof path === 'string') { @@ -51,7 +54,7 @@ export class AgentsFilesController { } else { normalizedPath = '.'; } - return await this.agentFileSystemService.readFile(agentId, normalizedPath); + return await this.agentFileSystemService.readFile(agentId, normalizedPath, context); } /** @@ -66,7 +69,9 @@ export class AgentsFilesController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() writeFileDto: WriteFileDto, + @Query('context') contextRaw?: string, ): Promise { + const context = parseAgentFileManagerContext(contextRaw); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -80,7 +85,13 @@ export class AgentsFilesController { if (!normalizedPath) { throw new BadRequestException('File path is required'); } - await this.agentFileSystemService.writeFile(agentId, normalizedPath, writeFileDto.content, writeFileDto.encoding); + await this.agentFileSystemService.writeFile( + agentId, + normalizedPath, + writeFileDto.content, + writeFileDto.encoding, + context, + ); } /** @@ -93,8 +104,10 @@ export class AgentsFilesController { async listDirectory( @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Query('path') path?: string, + @Query('context') contextRaw?: string, ): Promise { - return await this.agentFileSystemService.listDirectory(agentId, path || '.'); + const context = parseAgentFileManagerContext(contextRaw); + return await this.agentFileSystemService.listDirectory(agentId, path || '.', context); } /** @@ -109,7 +122,9 @@ export class AgentsFilesController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() createFileDto: CreateFileDto, + @Query('context') contextRaw?: string, ): Promise { + const context = parseAgentFileManagerContext(contextRaw); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -128,6 +143,7 @@ export class AgentsFilesController { normalizedPath, createFileDto.type, createFileDto.content, + context, ); } @@ -141,7 +157,9 @@ export class AgentsFilesController { async deleteFileOrDirectory( @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, + @Query('context') contextRaw?: string, ): Promise { + const context = parseAgentFileManagerContext(contextRaw); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -155,7 +173,7 @@ export class AgentsFilesController { if (!normalizedPath) { throw new BadRequestException('File path is required'); } - await this.agentFileSystemService.deleteFileOrDirectory(agentId, normalizedPath); + await this.agentFileSystemService.deleteFileOrDirectory(agentId, normalizedPath, context); } /** @@ -170,7 +188,9 @@ export class AgentsFilesController { @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, @Param('path') path: string | string[] | Record | undefined, @Body() moveFileDto: MoveFileDto, + @Query('context') contextRaw?: string, ): Promise { + const context = parseAgentFileManagerContext(contextRaw); // Normalize path: wildcard parameters can be string, array, object, or undefined let normalizedPath: string | undefined; if (typeof path === 'string') { @@ -187,6 +207,6 @@ export class AgentsFilesController { if (!moveFileDto.destination) { throw new BadRequestException('Destination path is required'); } - await this.agentFileSystemService.moveFileOrDirectory(agentId, normalizedPath, moveFileDto.destination); + await this.agentFileSystemService.moveFileOrDirectory(agentId, normalizedPath, moveFileDto.destination, context); } } diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts index 0dab6737..7d2f56b8 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agent-provider.interface.ts @@ -96,6 +96,13 @@ export interface AgentProvider { */ getBasePath?(): string; + /** + * Get the base path for the provider's configuration. + * This is used to construct the API base URL for the provider's configuration. + * @returns The base path string (e.g., '~/.cursor') + */ + getConfigBasePath?(): string; + /** * Get the path to the repository relative to the base path for the provider. * This is used to construct the repository path within agent containers. diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts index 91eb3ef2..ef01e5ad 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.spec.ts @@ -60,6 +60,12 @@ describe('CursorAgentProvider', () => { }); }); + describe('getConfigBasePath', () => { + it('should return "~/.cursor"', () => { + expect(provider.getConfigBasePath()).toBe('~/.cursor'); + }); + }); + describe('getDockerImage', () => { it('should return default image when CURSOR_AGENT_DOCKER_IMAGE is not set', () => { delete process.env.CURSOR_AGENT_DOCKER_IMAGE; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts index ba13eadb..c76cea8d 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts @@ -54,6 +54,15 @@ export class CursorAgentProvider implements AgentProvider { return '/app'; } + /** + * Get the base path for the provider's configuration. + * This is used to construct the API base URL for the provider's configuration. + * @returns The base path string (e.g., '~/.cursor') + */ + getConfigBasePath(): string { + return '~/.cursor'; + } + /** * Get the Docker image (including tag) to use for cursor-agent containers. * @returns The Docker image string diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts index 04a40344..617b1cae 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.spec.ts @@ -62,6 +62,12 @@ describe('OpenCodeAgentProvider', () => { }); }); + describe('getConfigBasePath', () => { + it('should return "~/.config/opencode"', () => { + expect(provider.getConfigBasePath()).toBe('~/.config/opencode'); + }); + }); + describe('getDockerImage', () => { it('should return default image when OPENCODE_AGENT_DOCKER_IMAGE is not set', () => { delete process.env.OPENCODE_AGENT_DOCKER_IMAGE; @@ -100,7 +106,7 @@ openrouter/z-ai/glm-5.1`; }); it('should drop empty lines and trim whitespace', () => { - const raw = ` model-a + const raw = ` model-a model-b `; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts index 6e313bbb..b34afe47 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/providers/agents/opencode-agent.provider.ts @@ -53,6 +53,15 @@ export class OpenCodeAgentProvider implements AgentProvider { return '/app'; } + /** + * Get the base path for the provider's configuration. + * This is used to construct the API base URL for the provider's configuration. + * @returns The base path string (e.g., '~/.config/opencode') + */ + getConfigBasePath(): string { + return '~/.config/opencode'; + } + /** * Get the Docker image (including tag) to use for opencode agent containers. * @returns The Docker image string diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts index d60f678a..19513d1a 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts @@ -49,10 +49,12 @@ describe('AgentFileSystemService', () => { sendCommandToContainer: jest.fn(), readFileFromContainer: jest.fn(), copyFileFromContainer: jest.fn(), + getContainerHomeDirectory: jest.fn().mockResolvedValue('/root'), }; const mockProvider = { getBasePath: jest.fn().mockReturnValue('/app'), + getConfigBasePath: jest.fn().mockReturnValue('~/.cursor'), }; const mockAgentProviderFactory = { @@ -93,6 +95,8 @@ describe('AgentFileSystemService', () => { jest.clearAllMocks(); jest.restoreAllMocks(); mockProvider.getBasePath.mockReturnValue('/app'); + mockProvider.getConfigBasePath = jest.fn().mockReturnValue('~/.cursor'); + mockDockerService.getContainerHomeDirectory.mockResolvedValue('/root'); mockAgentProviderFactory.getProvider.mockReturnValue(mockProvider); }); @@ -726,4 +730,40 @@ file|file2.txt|2048|1704067200`; ); }); }); + + describe('file manager context=config', () => { + it('should read file under expanded config root', async () => { + const filePath = 'settings.json'; + const fileContent = '{}'; + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + dockerService.copyFileFromContainer.mockResolvedValue(undefined); + + const mockTempDir = '/tmp/agent-file-read-abc123'; + jest.spyOn(fs, 'mkdtempSync').mockReturnValue(mockTempDir); + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'statSync').mockReturnValue({ size: fileContent.length } as fs.Stats); + jest.spyOn(fs, 'readFileSync').mockReturnValue(Buffer.from(fileContent, 'utf-8')); + jest.spyOn(fs, 'unlinkSync').mockImplementation(jest.fn()); + jest.spyOn(fs, 'rmSync').mockImplementation(jest.fn()); + + await service.readFile(mockAgentId, filePath, 'config'); + + expect(dockerService.getContainerHomeDirectory).toHaveBeenCalledWith(mockContainerId); + expect(dockerService.copyFileFromContainer).toHaveBeenCalledWith( + mockContainerId, + '/root/.cursor/settings.json', + expect.any(String), + ); + }); + + it('should throw BadRequestException when provider has no getConfigBasePath', async () => { + const noConfig = { getBasePath: () => '/app' }; + mockAgentProviderFactory.getProvider.mockReturnValue(noConfig as never); + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findByIdOrThrow.mockResolvedValue(mockAgentEntity); + + await expect(service.readFile(mockAgentId, 'x', 'config')).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.ts index 057c402d..c0d17b8a 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-file-system.service.ts @@ -6,6 +6,8 @@ import { FileContentDto } from '../dto/file-content.dto'; import { FileNodeDto } from '../dto/file-node.dto'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentsRepository } from '../repositories/agents.repository'; +import type { AgentFileManagerContext } from '../utils/agent-file-manager-context'; +import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; import { AgentsService } from './agents.service'; import { DockerService } from './docker.service'; @@ -18,6 +20,7 @@ export class AgentFileSystemService { private readonly logger = new Logger(AgentFileSystemService.name); private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB private readonly DEFAULT_BASE_PATH = '/app'; + private static readonly CONFIG_NOT_SUPPORTED = 'Agent provider does not support agent-wide configuration file access'; constructor( private readonly agentsService: AgentsService, @@ -80,16 +83,56 @@ export class AgentFileSystemService { } /** - * Build the full container path from a relative path. - * Uses the provider's base path if available, otherwise defaults to '/app'. - * @param relativePath - The relative path from the base path - * @param agentType - The agent type identifier - * @returns The full container path + * Expand leading `~` in a provider path using the container user's HOME. */ - private buildContainerPath(relativePath: string, agentType: string): string { + private async expandTildeInProviderPath(providerPath: string, containerId: string): Promise { + return expandProviderPathTildeInContainer(providerPath, containerId, (id) => + this.dockerService.getContainerHomeDirectory(id), + ); + } + + /** + * Resolve absolute filesystem root inside the container for the given context. + */ + private async resolveFilesystemRoot( + agentType: string, + context: AgentFileManagerContext, + containerId: string, + ): Promise { + if (context === 'app') { + return this.getBasePath(agentType); + } + try { + const provider = this.agentProviderFactory.getProvider(agentType); + if (!provider.getConfigBasePath) { + throw new BadRequestException(AgentFileSystemService.CONFIG_NOT_SUPPORTED); + } + const raw = provider.getConfigBasePath(); + if (!raw?.trim()) { + throw new BadRequestException(AgentFileSystemService.CONFIG_NOT_SUPPORTED); + } + return await this.expandTildeInProviderPath(raw.trim(), containerId); + } catch (error: unknown) { + if (error instanceof BadRequestException) { + throw error; + } + this.logger.warn(`Failed to resolve config base for agent type '${agentType}': ${error}`); + throw new BadRequestException(AgentFileSystemService.CONFIG_NOT_SUPPORTED); + } + } + + /** + * Build the full container path from a relative path and context root. + */ + private async buildContainerPath( + relativePath: string, + agentType: string, + context: AgentFileManagerContext, + containerId: string, + ): Promise { const sanitized = this.sanitizePath(relativePath); - const basePath = this.getBasePath(agentType); - return `${basePath}/${sanitized}`; + const root = (await this.resolveFilesystemRoot(agentType, context, containerId)).replace(/\/+$/, ''); + return `${root}/${sanitized}`; } /** @@ -98,11 +141,12 @@ export class AgentFileSystemService { * This approach avoids corruption issues that can occur with shell commands, especially for binary files. * @param agentId - The UUID of the agent * @param filePath - The relative path to the file (from the provider's base path, defaults to /app) + * @param context - `app` (workspace) or `config` (provider config directory) * @returns File content (base64-encoded) and encoding type * @throws NotFoundException if agent or file is not found * @throws BadRequestException if path is invalid or file is too large */ - async readFile(agentId: string, filePath: string): Promise { + async readFile(agentId: string, filePath: string, context: AgentFileManagerContext = 'app'): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -110,7 +154,12 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const containerPath = this.buildContainerPath(filePath, agentEntity.agentType); + const containerPath = await this.buildContainerPath( + filePath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); let tempFilePath: string | null = null; try { @@ -234,10 +283,17 @@ export class AgentFileSystemService { * @param filePath - The relative path to the file (from the provider's base path, defaults to /app) * @param content - The file content as base64-encoded string * @param encoding - Optional encoding indicator ('utf-8' or 'base64') + * @param context - `app` or `config` * @throws NotFoundException if agent is not found * @throws BadRequestException if path is invalid or content is too large */ - async writeFile(agentId: string, filePath: string, content: string, encoding?: 'utf-8' | 'base64'): Promise { + async writeFile( + agentId: string, + filePath: string, + content: string, + encoding?: 'utf-8' | 'base64', + context: AgentFileManagerContext = 'app', + ): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -245,7 +301,12 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const containerPath = this.buildContainerPath(filePath, agentEntity.agentType); + const containerPath = await this.buildContainerPath( + filePath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); // Content is already base64-encoded, so we check the approximate decoded size // Base64 is ~33% larger: original_size ≈ base64_length * 3/4 @@ -282,10 +343,15 @@ export class AgentFileSystemService { * @param agentId - The UUID of the agent * @param directoryPath - The relative path to the directory (from the provider's base path, defaults to /app), defaults to '.' * @returns Array of file nodes + * @param context - `app` or `config` * @throws NotFoundException if agent or directory is not found * @throws BadRequestException if path is invalid */ - async listDirectory(agentId: string, directoryPath = '.'): Promise { + async listDirectory( + agentId: string, + directoryPath = '.', + context: AgentFileManagerContext = 'app', + ): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -293,7 +359,12 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const containerPath = this.buildContainerPath(directoryPath, agentEntity.agentType); + const containerPath = await this.buildContainerPath( + directoryPath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); try { // Use a simpler approach: ls to list, then process with find for each item @@ -429,6 +500,7 @@ export class AgentFileSystemService { * @param filePath - The relative path to create (from the provider's base path, defaults to /app) * @param type - The type to create ('file' or 'directory') * @param content - Optional content for file creation + * @param context - `app` or `config` * @throws NotFoundException if agent is not found * @throws BadRequestException if path is invalid or file already exists */ @@ -437,6 +509,7 @@ export class AgentFileSystemService { filePath: string, type: 'file' | 'directory', content?: string, + context: AgentFileManagerContext = 'app', ): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -445,7 +518,12 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const containerPath = this.buildContainerPath(filePath, agentEntity.agentType); + const containerPath = await this.buildContainerPath( + filePath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); try { if (type === 'directory') { @@ -458,7 +536,7 @@ export class AgentFileSystemService { // Create file with optional content if (content !== undefined) { // Content should be base64-encoded - await this.writeFile(agentId, filePath, content, 'utf-8'); + await this.writeFile(agentId, filePath, content, 'utf-8', context); } else { // Create empty file await this.dockerService.sendCommandToContainer( @@ -480,10 +558,15 @@ export class AgentFileSystemService { * Delete a file or directory from agent container. * @param agentId - The UUID of the agent * @param filePath - The relative path to delete (from the provider's base path, defaults to /app) + * @param context - `app` or `config` * @throws NotFoundException if agent or file is not found * @throws BadRequestException if path is invalid */ - async deleteFileOrDirectory(agentId: string, filePath: string): Promise { + async deleteFileOrDirectory( + agentId: string, + filePath: string, + context: AgentFileManagerContext = 'app', + ): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -491,7 +574,12 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const containerPath = this.buildContainerPath(filePath, agentEntity.agentType); + const containerPath = await this.buildContainerPath( + filePath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); try { // Use rm -rf to delete file or directory @@ -516,10 +604,16 @@ export class AgentFileSystemService { * @param agentId - The UUID of the agent * @param sourcePath - The relative path to the source file/directory (from the provider's base path, defaults to /app) * @param destinationPath - The relative path to the destination (from the provider's base path, defaults to /app) + * @param context - `app` or `config` * @throws NotFoundException if agent, source file, or destination directory is not found * @throws BadRequestException if paths are invalid */ - async moveFileOrDirectory(agentId: string, sourcePath: string, destinationPath: string): Promise { + async moveFileOrDirectory( + agentId: string, + sourcePath: string, + destinationPath: string, + context: AgentFileManagerContext = 'app', + ): Promise { await this.agentsService.findOne(agentId); const agentEntity = await this.agentsRepository.findByIdOrThrow(agentId); @@ -527,8 +621,18 @@ export class AgentFileSystemService { throw new NotFoundException(`Agent ${agentId} has no associated container`); } - const sourceContainerPath = this.buildContainerPath(sourcePath, agentEntity.agentType); - const destinationContainerPath = this.buildContainerPath(destinationPath, agentEntity.agentType); + const sourceContainerPath = await this.buildContainerPath( + sourcePath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); + const destinationContainerPath = await this.buildContainerPath( + destinationPath, + agentEntity.agentType, + context, + agentEntity.containerId, + ); try { // Use mv command to move file or directory diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts index 2243b81d..e05806d9 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts @@ -55,6 +55,7 @@ describe('AgentsService', () => { createNetwork: jest.fn(), deleteNetwork: jest.fn(), sendCommandToContainer: jest.fn(), + getContainerHomeDirectory: jest.fn().mockResolvedValue('/root'), startContainer: jest.fn(), stopContainer: jest.fn(), restartContainer: jest.fn(), @@ -78,6 +79,8 @@ describe('AgentsService', () => { toUnifiedResponse: jest.fn(), getModelsListCommand: jest.fn().mockReturnValue('cursor-agent --list-models'), toModelsList: jest.fn().mockReturnValue({}), + getBasePath: jest.fn().mockReturnValue('/app'), + getConfigBasePath: jest.fn().mockReturnValue('~/.cursor'), }; const mockAgentProviderFactory = { @@ -148,6 +151,12 @@ describe('AgentsService', () => { process.env.GIT_REPOSITORY_URL = 'https://github.com/user/repo.git'; }); + afterEach(() => { + // Tests may delete optional provider methods; restore defaults for isolation + mockAgentProvider.getBasePath = jest.fn().mockReturnValue('/app'); + mockAgentProvider.getConfigBasePath = jest.fn().mockReturnValue('~/.cursor'); + }); + it('should create new agent with auto-generated password and container', async () => { const createDto: CreateAgentDto = { name: 'New Agent', @@ -206,8 +215,8 @@ describe('AgentsService', () => { }, ], }); - // Verify .netrc file creation commands were called (2 commands: base64 write + chmod) - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); // 2 for .netrc + 1 for git clone + // Verify .netrc file creation commands were called (2 commands: base64 write + chmod), then config dir, then git clone + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); // 2 for .netrc + 1 for config mkdir + 1 for git clone expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( 1, containerId, @@ -218,6 +227,14 @@ describe('AgentsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( 3, containerId, + `sh -c "mkdir -p -- '/root/.cursor'"`, + undefined, + true, + ); + expect(dockerService.getContainerHomeDirectory).toHaveBeenCalledWith(containerId); + expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( + 4, + containerId, expect.stringMatching(/sh -c "git clone '[^']+' '\/app'"/), ); expect(repository.create).toHaveBeenCalledWith({ @@ -287,7 +304,7 @@ describe('AgentsService', () => { ], }); // Verify .netrc file creation was called - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); // 2 for .netrc + 1 for git clone + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); // 2 for .netrc + 1 for config mkdir + 1 for git clone expect(repository.create).toHaveBeenCalledWith({ name: createDto.name, description: undefined, @@ -382,7 +399,7 @@ describe('AgentsService', () => { await service.create(createDto); // Verify .netrc creation was called (should use GIT_PASSWORD) - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); // 2 for .netrc + 1 for git clone + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); // 2 for .netrc + 1 for config mkdir + 1 for git clone }); it('should throw BadRequestException when agent name already exists', async () => { @@ -434,6 +451,7 @@ describe('AgentsService', () => { dockerService.sendCommandToContainer .mockResolvedValueOnce(undefined) // First call for .netrc base64 write succeeds .mockResolvedValueOnce(undefined) // Second call for chmod succeeds + .mockResolvedValueOnce(undefined) // mkdir provider config dir .mockRejectedValueOnce(gitCloneError); // Git clone fails dockerService.deleteContainer.mockResolvedValue(undefined); @@ -441,8 +459,8 @@ describe('AgentsService', () => { // Verify container was created expect(dockerService.createContainer).toHaveBeenCalled(); - // Verify .netrc creation (2 commands) and git clone (1 attempt) were called - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); + // Verify .netrc creation (2 commands), config mkdir, and git clone (1 attempt) were called + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); // Verify container cleanup was attempted expect(dockerService.deleteContainer).toHaveBeenCalledWith(containerId); // Verify repository.create was never called @@ -467,8 +485,8 @@ describe('AgentsService', () => { // Verify container was created expect(dockerService.createContainer).toHaveBeenCalled(); - // Verify .netrc creation (2 commands) and git clone (1 command) were attempted - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); + // Verify .netrc creation (2 commands), config mkdir, and git clone (1 command) were attempted + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(4); // Verify repository.create was attempted expect(repository.create).toHaveBeenCalled(); // Verify container cleanup was attempted @@ -489,6 +507,7 @@ describe('AgentsService', () => { dockerService.sendCommandToContainer .mockResolvedValueOnce(undefined) // First call for .netrc base64 write succeeds .mockResolvedValueOnce(undefined) // Second call for chmod succeeds + .mockResolvedValueOnce(undefined) // mkdir provider config dir .mockRejectedValueOnce(originalError); // Git clone fails dockerService.deleteContainer.mockRejectedValue(cleanupError); @@ -559,8 +578,8 @@ describe('AgentsService', () => { }, ], }); - // Verify SSH setup commands: mkdir, chmod .ssh, write key, chmod key, ssh-keyscan, chmod known_hosts, git clone - expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(7); + // Verify SSH setup commands, provider config mkdir, then git clone + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(8); expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith(1, containerId, 'mkdir -p /root/.ssh'); expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith(2, containerId, 'chmod 700 /root/.ssh'); expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( @@ -586,6 +605,13 @@ describe('AgentsService', () => { expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( 7, containerId, + `sh -c "mkdir -p -- '/root/.cursor'"`, + undefined, + true, + ); + expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( + 8, + containerId, expect.stringMatching(/sh -c "git clone .*git@github\.com:user\/repo\.git.*'\/app'"/), ); expect(repository.create).toHaveBeenCalled(); @@ -635,7 +661,7 @@ describe('AgentsService', () => { // Verify git clone uses basePath + repositoryPath for SSH repository const expectedPath = basePath + repositoryPath; expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 7, + 8, containerId, expect.stringMatching( new RegExp(`sh -c "git clone .*git@github\\.com:user/repo\\.git.*'${expectedPath.replace(/'/g, "'\\''")}'"`), @@ -825,7 +851,7 @@ describe('AgentsService', () => { // Verify git clone uses the custom base path (escaped for shell) // Since getRepositoryPath is not defined, it should use just basePath expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(new RegExp(`sh -c "git clone '[^']+' '${customBasePath.replace(/'/g, "'\\''")}'"`)), ); @@ -868,7 +894,7 @@ describe('AgentsService', () => { // Verify git clone uses basePath + repositoryPath const expectedPath = basePath + repositoryPath; expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(new RegExp(`sh -c "git clone '[^']+' '${expectedPath.replace(/'/g, "'\\''")}'"`)), ); @@ -931,7 +957,7 @@ describe('AgentsService', () => { // Verify git clone uses basePath + repositoryPath const expectedPath = customBasePath + repositoryPath; expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(new RegExp(`sh -c "git clone '[^']+' '${expectedPath.replace(/'/g, "'\\''")}'"`)), ); @@ -972,7 +998,7 @@ describe('AgentsService', () => { expect(mockAgentProvider.getBasePath).toHaveBeenCalled(); // Verify git clone uses only basePath when getRepositoryPath is not defined expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(new RegExp(`sh -c "git clone '[^']+' '${customBasePath.replace(/'/g, "'\\''")}'"`)), ); @@ -1031,7 +1057,7 @@ describe('AgentsService', () => { }); // Verify git clone uses '/app' (escaped) when getRepositoryPath is not defined expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(/sh -c "git clone '[^']+' '\/app'"/), ); @@ -1090,7 +1116,7 @@ describe('AgentsService', () => { }); // Verify git clone uses '/app' (escaped) when getRepositoryPath is not defined expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( - 3, + 4, containerId, expect.stringMatching(/sh -c "git clone '[^']+' '\/app'"/), ); @@ -1340,6 +1366,72 @@ describe('AgentsService', () => { expect(result.id).toBe(mockAgent.id); expect(result.name).toBe(createDto.name); }); + + it('should not mkdir provider config when getConfigBasePath is not defined', async () => { + const createDto: CreateAgentDto = { + name: 'Agent Without Config Base', + containerType: ContainerType.GENERIC, + }; + const hashedPassword = 'hashed-password'; + const containerId = 'container-id-123'; + const createdAgent = { + ...mockAgent, + name: createDto.name, + hashedPassword, + containerId, + volumePath: '/opt/agents/vol', + }; + + delete (mockAgentProvider as { getConfigBasePath?: () => string }).getConfigBasePath; + mockAgentProvider.getVirtualWorkspaceDockerImage.mockReturnValueOnce(undefined); + mockAgentProvider.getSshConnectionDockerImage.mockReturnValueOnce(undefined); + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue(hashedPassword); + dockerService.createContainer.mockResolvedValue(containerId); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + repository.create.mockResolvedValue(createdAgent); + + await service.create(createDto); + + expect(dockerService.sendCommandToContainer).toHaveBeenCalledTimes(3); + expect(dockerService.getContainerHomeDirectory).not.toHaveBeenCalled(); + }); + + it('should mkdir absolute provider config path without calling getContainerHomeDirectory', async () => { + const createDto: CreateAgentDto = { + name: 'Agent Abs Config', + containerType: ContainerType.GENERIC, + }; + const hashedPassword = 'hashed-password'; + const containerId = 'container-id-abc'; + const createdAgent = { + ...mockAgent, + name: createDto.name, + hashedPassword, + containerId, + volumePath: '/opt/agents/vol2', + }; + + mockAgentProvider.getConfigBasePath = jest.fn().mockReturnValue('/var/my-agent-config'); + mockAgentProvider.getVirtualWorkspaceDockerImage.mockReturnValueOnce(undefined); + mockAgentProvider.getSshConnectionDockerImage.mockReturnValueOnce(undefined); + mockRepository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue(hashedPassword); + dockerService.createContainer.mockResolvedValue(containerId); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + repository.create.mockResolvedValue(createdAgent); + + await service.create(createDto); + + expect(dockerService.getContainerHomeDirectory).not.toHaveBeenCalled(); + expect(dockerService.sendCommandToContainer).toHaveBeenNthCalledWith( + 3, + containerId, + `sh -c "mkdir -p -- '/var/my-agent-config'"`, + undefined, + true, + ); + }); }); describe('findAll', () => { diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts index 5e539e91..bfa241b7 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -11,6 +11,7 @@ import { AgentEntity, ContainerType } from '../entities/agent.entity'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentProviderModels } from '../providers/agent-provider.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; import { DeploymentsService } from './deployments.service'; import { DockerService } from './docker.service'; @@ -74,6 +75,26 @@ export class AgentsService implements OnApplicationBootstrap { return `'${str.replace(/'/g, "'\\''")}'`; } + /** + * Ensure the provider agent config directory exists inside the container when the provider defines one. + * Uses `mkdir -p` so nested paths (e.g. ~/.config/opencode) are created idempotently. + */ + private async ensureProviderConfigBaseDirectoryExists(containerId: string, agentType: string): Promise { + const provider = this.agentProviderFactory.getProvider(agentType); + if (!provider.getConfigBasePath) { + return; + } + const raw = provider.getConfigBasePath()?.trim(); + if (!raw) { + return; + } + const expanded = await expandProviderPathTildeInContainer(raw, containerId, (id) => + this.dockerService.getContainerHomeDirectory(id), + ); + const escaped = this.escapeForShell(expanded); + await this.dockerService.sendCommandToContainer(containerId, `sh -c "mkdir -p -- ${escaped}"`, undefined, true); + } + /** * Determine whether the configured git repository uses SSH. */ @@ -299,6 +320,8 @@ export class AgentsService implements OnApplicationBootstrap { await this.createNetrcFile(containerId, repositoryUrl); } + await this.ensureProviderConfigBaseDirectoryExists(containerId, agentType); + const escapedUrl = this.escapeForShell(repositoryUrl); const repositoryPath = provider.getRepositoryPath ? basePath + provider.getRepositoryPath() : basePath; const escapedRepositoryPath = this.escapeForShell(repositoryPath); 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 3c907b12..8564c09f 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 @@ -1232,6 +1232,97 @@ export class DockerService { } } + /** + * Resolve the container user's home directory (for tilde expansion in provider config paths). + * @param containerId - Docker container ID + * @returns Trimmed HOME path, or `/root` when empty + */ + async getContainerHomeDirectory(containerId: string): Promise { + try { + const container = this.docker.getContainer(containerId); + try { + await container.inspect(); + } catch (error: unknown) { + const dockerError = error as { statusCode?: number }; + if (dockerError.statusCode === 404) { + throw new NotFoundException(`Container with ID '${containerId}' not found`); + } + throw error; + } + + const exec = await container.exec({ + Cmd: ['sh', '-c', 'printf %s "${HOME:-/root}"'], + AttachStdin: false, + AttachStdout: true, + AttachStderr: true, + Tty: false, + }); + + const stream = (await exec.start({ + hijack: true, + stdin: false, + })) as NodeJS.ReadWriteStream; + + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + let stdoutData = ''; + container.modem.demuxStream(stream, stdoutStream, stderrStream); + stdoutStream.on('data', (chunk: Buffer) => { + stdoutData += chunk.toString('utf8'); + }); + + const output = await new Promise((resolve, reject) => { + let resolved = false; + const resolveOnce = (v: string) => { + if (!resolved) { + resolved = true; + resolve(v); + } + }; + const rejectOnce = (err: unknown) => { + if (!resolved) { + resolved = true; + reject(err); + } + }; + stream.on('end', () => { + stdoutStream.end(); + stderrStream.end(); + resolveOnce(stdoutData); + }); + stream.on('close', () => { + if (!resolved) { + stdoutStream.end(); + stderrStream.end(); + resolveOnce(stdoutData); + } + }); + stream.on('error', (err: unknown) => { + stdoutStream.end(); + stderrStream.end(); + rejectOnce(err); + }); + setTimeout(() => { + if (!resolved) { + stdoutStream.end(); + stderrStream.end(); + resolveOnce(stdoutData); + } + }, 60000); + }); + + const home = output.trim() || '/root'; + return home; + } catch (error: unknown) { + if (error instanceof NotFoundException) { + throw error; + } + const err = error as { message?: string; stack?: string }; + this.logger.error(`Error resolving HOME in container: ${err.message}`, err.stack); + throw error; + } + } + /** * Create a new terminal session (TTY) for a container. * Creates a persistent TTY exec instance that can be used for interactive terminal sessions. diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.spec.ts new file mode 100644 index 00000000..74113b5b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.spec.ts @@ -0,0 +1,23 @@ +import { BadRequestException } from '@nestjs/common'; +import { parseAgentFileManagerContext } from './agent-file-manager-context'; + +describe('parseAgentFileManagerContext', () => { + it('defaults to app when undefined or empty', () => { + expect(parseAgentFileManagerContext(undefined)).toBe('app'); + expect(parseAgentFileManagerContext('')).toBe('app'); + expect(parseAgentFileManagerContext(' ')).toBe('app'); + }); + + it('accepts app and config', () => { + expect(parseAgentFileManagerContext('app')).toBe('app'); + expect(parseAgentFileManagerContext('config')).toBe('config'); + }); + + it('trims whitespace', () => { + expect(parseAgentFileManagerContext(' config ')).toBe('config'); + }); + + it('rejects invalid values', () => { + expect(() => parseAgentFileManagerContext('workspace')).toThrow(BadRequestException); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.ts new file mode 100644 index 00000000..ccc11360 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/agent-file-manager-context.ts @@ -0,0 +1,18 @@ +import { BadRequestException } from '@nestjs/common'; + +/** Root for proxied file operations: application workspace vs provider agent config directory. */ +export type AgentFileManagerContext = 'app' | 'config'; + +/** + * Parse optional `context` query param. Omitted or empty defaults to `app` for backward compatibility. + */ +export function parseAgentFileManagerContext(value: string | undefined): AgentFileManagerContext { + if (value === undefined || value === null || value.trim() === '') { + return 'app'; + } + const v = value.trim(); + if (v === 'app' || v === 'config') { + return v; + } + throw new BadRequestException(`Invalid context: ${value}. Allowed values are "app" and "config".`); +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.spec.ts new file mode 100644 index 00000000..434e55ce --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.spec.ts @@ -0,0 +1,22 @@ +import { expandProviderPathTildeInContainer } from './provider-container-path.utils'; + +describe('expandProviderPathTildeInContainer', () => { + const containerId = 'abc123'; + + it('returns HOME when path is exactly ~', async () => { + const getHome = jest.fn().mockResolvedValue('/root'); + await expect(expandProviderPathTildeInContainer('~', containerId, getHome)).resolves.toBe('/root'); + expect(getHome).toHaveBeenCalledWith(containerId); + }); + + it('expands ~/suffix', async () => { + const getHome = jest.fn().mockResolvedValue('/root'); + await expect(expandProviderPathTildeInContainer('~/.cursor', containerId, getHome)).resolves.toBe('/root/.cursor'); + }); + + it('returns absolute paths unchanged', async () => { + const getHome = jest.fn(); + await expect(expandProviderPathTildeInContainer('/etc/foo', containerId, getHome)).resolves.toBe('/etc/foo'); + expect(getHome).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.ts new file mode 100644 index 00000000..4eeb40a8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/utils/provider-container-path.utils.ts @@ -0,0 +1,18 @@ +/** + * Expand a leading `~` in a provider path using the container user's HOME. + * Used for provider config roots (e.g. `~/.cursor`) and kept in sync with file operations. + */ +export async function expandProviderPathTildeInContainer( + providerPath: string, + containerId: string, + getContainerHomeDirectory: (id: string) => Promise, +): Promise { + if (providerPath === '~') { + return await getContainerHomeDirectory(containerId); + } + if (providerPath.startsWith('~/')) { + const home = await getContainerHomeDirectory(containerId); + return `${home}${providerPath.slice(1)}`; + } + return providerPath; +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/docs/overview.mmd b/libs/domains/framework/frontend/data-access-agent-console/docs/overview.mmd index 90ca9796..2eabac94 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/docs/overview.mmd +++ b/libs/domains/framework/frontend/data-access-agent-console/docs/overview.mmd @@ -2,10 +2,10 @@ flowchart TB subgraph STATE["📦 NgRx State Management"] direction TB STATE_USE["Use Case: File System State
Reactive State Management
Caching & Loading States"] - STATE_USE --> STATE1["📄 File Contents Cache
Key: clientId:agentId:filePath
Value: FileContentDto (base64)"] - STATE_USE --> STATE2["📂 Directory Listings Cache
Key: clientId:agentId:directoryPath
Value: FileNodeDto[]"] + STATE_USE --> STATE1["📄 File Contents Cache
Key: clientId:agentId:context:filePath
Value: FileContentDto (base64)"] + STATE_USE --> STATE2["📂 Directory Listings Cache
Key: clientId:agentId:context:directoryPath
Value: FileNodeDto[]"] STATE_USE --> STATE3["⏳ Loading States
reading, writing, listing,
creating, deleting"] - STATE_USE --> STATE4["❌ Error States
Key: clientId:agentId:path
Value: error message"] + STATE_USE --> STATE4["❌ Error States
Key: clientId:agentId:context:path
Value: error message"] end subgraph FACADE["🎭 FilesFacade"] @@ -32,11 +32,11 @@ flowchart TB subgraph SERVICE["🌐 FilesService"] direction TB SERVICE_USE["Use Case: HTTP Client
REST API Communication
Base64 Encoding"] - SERVICE_USE --> SERVICE1["readFile()
GET /api/clients/{id}/agents/{agentId}/files/{path}"] - SERVICE_USE --> SERVICE2["writeFile()
PUT /api/clients/{id}/agents/{agentId}/files/{path}"] - SERVICE_USE --> SERVICE3["listDirectory()
GET /api/clients/{id}/agents/{agentId}/files?path="] - SERVICE_USE --> SERVICE4["createFileOrDirectory()
POST /api/clients/{id}/agents/{agentId}/files/{path}"] - SERVICE_USE --> SERVICE5["deleteFileOrDirectory()
DELETE /api/clients/{id}/agents/{agentId}/files/{path}"] + SERVICE_USE --> SERVICE1["readFile()
GET .../files/{path}?context=app|config (optional)"] + SERVICE_USE --> SERVICE2["writeFile()
PUT .../files/{path}?context=app|config (optional)"] + SERVICE_USE --> SERVICE3["listDirectory()
GET .../files?path= plus optional context"] + SERVICE_USE --> SERVICE4["createFileOrDirectory()
POST .../files/{path}?context= (optional)"] + SERVICE_USE --> SERVICE5["deleteFileOrDirectory()
DELETE .../files/{path}?context= (optional)"] end subgraph API["🔷 HTTP REST API"] diff --git a/libs/domains/framework/frontend/data-access-agent-console/docs/sequence-files.mmd b/libs/domains/framework/frontend/data-access-agent-console/docs/sequence-files.mmd index 36ae88a2..6a2e753f 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/docs/sequence-files.mmd +++ b/libs/domains/framework/frontend/data-access-agent-console/docs/sequence-files.mmd @@ -4,23 +4,23 @@ sequenceDiagram participant Store as NgRx Store participant Effects as FilesEffects participant Service as FilesService - participant API as HTTP API
(/api/clients/:id/agents/:agentId/files) + participant API as HTTP API
(/api/clients/:id/agents/:agentId/files?context= optional) Note over Component,API: NgRx File System Operations Flow rect rgb(230, 240, 255) Note over Component,API: Read File - Component->>Facade: readFile(clientId, agentId, filePath) - Facade->>Store: dispatch(readFile({clientId, agentId, filePath})) + Component->>Facade: readFile(clientId, agentId, filePath, context?) + Facade->>Store: dispatch(readFile({clientId, agentId, filePath, context?})) Store->>Store: Update state: reading[key] = true Store->>Effects: readFile$ effect triggered - Effects->>Service: readFile(clientId, agentId, filePath) - Service->>API: GET /api/clients/{id}/agents/{agentId}/files/{path} + Effects->>Service: readFile(clientId, agentId, filePath, context default app) + Service->>API: GET .../files/{path}?context=config when not app API-->>Service: 200 OK
{content: base64, encoding: 'utf-8'|'base64'} Service-->>Effects: Observable - Effects->>Store: dispatch(readFileSuccess({clientId, agentId, filePath, content})) - Store->>Store: Update state:
fileContents[key] = content
reading[key] = false - Component->>Facade: getFileContent$(clientId, agentId, filePath) + Effects->>Store: dispatch(readFileSuccess({clientId, agentId, filePath, content, context})) + Store->>Store: Update state:
fileContents[clientId:agentId:context:path] = content
reading[key] = false + Component->>Facade: getFileContent$(clientId, agentId, filePath, context?) Facade->>Store: select(selectFileContent(...)) Store-->>Facade: Observable Facade-->>Component: Observable @@ -28,15 +28,15 @@ sequenceDiagram rect rgb(240, 255, 240) Note over Component,API: Write File - Component->>Facade: writeFile(clientId, agentId, filePath, writeFileDto) - Facade->>Store: dispatch(writeFile({clientId, agentId, filePath, writeFileDto})) + Component->>Facade: writeFile(clientId, agentId, filePath, writeFileDto, context?) + Facade->>Store: dispatch(writeFile({clientId, agentId, filePath, writeFileDto, context?})) Store->>Store: Update state: writing[key] = true Store->>Effects: writeFile$ effect triggered - Effects->>Service: writeFile(clientId, agentId, filePath, writeFileDto) - Service->>API: PUT /api/clients/{id}/agents/{agentId}/files/{path}
{content: base64, encoding?} + Effects->>Service: writeFile(..., context default app) + Service->>API: PUT .../files/{path} optional ?context=config
{content: base64, encoding?} API-->>Service: 204 No Content Service-->>Effects: Observable - Effects->>Store: dispatch(writeFileSuccess({clientId, agentId, filePath})) + Effects->>Store: dispatch(writeFileSuccess({clientId, agentId, filePath, context})) Store->>Store: Update state:
Remove fileContents[key]
writing[key] = false Component->>Facade: isWritingFile$(clientId, agentId, filePath) Facade->>Store: select(selectIsWritingFile(...)) @@ -51,10 +51,10 @@ sequenceDiagram Store->>Store: Update state: listing[key] = true Store->>Effects: listDirectory$ effect triggered Effects->>Service: listDirectory(clientId, agentId, params) - Service->>API: GET /api/clients/{id}/agents/{agentId}/files?path={dir} + Service->>API: GET .../files?path={dir} optional context API-->>Service: 200 OK
[file nodes] Service-->>Effects: Observable - Effects->>Store: dispatch(listDirectorySuccess({clientId, agentId, directoryPath, files})) + Effects->>Store: dispatch(listDirectorySuccess({clientId, agentId, directoryPath, files, context})) Store->>Store: Update state:
directoryListings[key] = files
listing[key] = false Component->>Facade: getDirectoryListing$(clientId, agentId, directoryPath) Facade->>Store: select(selectDirectoryListing(...)) diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.spec.ts index d46a254e..26196a6f 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.spec.ts @@ -72,6 +72,22 @@ describe('FilesService', () => { req.flush(mockFileContent); }); + it('should append context=config for config file manager context', (done) => { + const filePath = 'settings.json'; + + service.readFile(clientId, agentId, filePath, 'config').subscribe((content) => { + expect(content).toEqual(mockFileContent); + done(); + }); + + const req = httpMock.expectOne( + `${apiUrl}/clients/${clientId}/agents/${agentId}/files/${filePath}?context=config`, + ); + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('context')).toBe('config'); + req.flush(mockFileContent); + }); + it('should encode file path segments separately preserving forward slashes', (done) => { const filePath = 'folder/sub folder/file with spaces.txt'; const expectedPath = 'folder/sub%20folder/file%20with%20spaces.txt'; @@ -103,6 +119,25 @@ describe('FilesService', () => { expect(req.request.body).toEqual(writeDto); req.flush(null); }); + + it('should append context=config on write when requested', (done) => { + const filePath = 'settings.json'; + const writeDto: WriteFileDto = { + content: Buffer.from('x', 'utf-8').toString('base64'), + encoding: 'utf-8', + }; + + service.writeFile(clientId, agentId, filePath, writeDto, 'config').subscribe(() => { + done(); + }); + + const req = httpMock.expectOne( + `${apiUrl}/clients/${clientId}/agents/${agentId}/files/${filePath}?context=config`, + ); + expect(req.request.method).toBe('PUT'); + expect(req.request.params.get('context')).toBe('config'); + req.flush(null); + }); }); describe('listDirectory', () => { @@ -130,6 +165,22 @@ describe('FilesService', () => { expect(req.request.params.get('path')).toBe('subdirectory'); req.flush(mockFileNodes); }); + + it('should include context=config with list when params request config root', (done) => { + service.listDirectory(clientId, agentId, { path: '.', context: 'config' }).subscribe((files) => { + expect(files).toEqual(mockFileNodes); + done(); + }); + + const req = httpMock.expectOne( + (r) => + r.url.startsWith(`${apiUrl}/clients/${clientId}/agents/${agentId}/files`) && + r.params.get('context') === 'config' && + r.params.get('path') === '.', + ); + expect(req.request.method).toBe('GET'); + req.flush(mockFileNodes); + }); }); describe('createFileOrDirectory', () => { @@ -150,6 +201,25 @@ describe('FilesService', () => { req.flush(null); }); + it('should append context=config on create when requested', (done) => { + const filePath = 'rules.md'; + const createDto: CreateFileDto = { + type: 'file', + content: Buffer.from('x', 'utf-8').toString('base64'), + }; + + service.createFileOrDirectory(clientId, agentId, filePath, createDto, 'config').subscribe(() => { + done(); + }); + + const req = httpMock.expectOne( + `${apiUrl}/clients/${clientId}/agents/${agentId}/files/${filePath}?context=config`, + ); + expect(req.request.method).toBe('POST'); + expect(req.request.params.get('context')).toBe('config'); + req.flush(null); + }); + it('should create a directory', (done) => { const directoryPath = 'new-directory'; const createDto: CreateFileDto = { @@ -179,6 +249,21 @@ describe('FilesService', () => { expect(req.request.method).toBe('DELETE'); req.flush(null); }); + + it('should append context=config on delete when requested', (done) => { + const filePath = 'old.json'; + + service.deleteFileOrDirectory(clientId, agentId, filePath, 'config').subscribe(() => { + done(); + }); + + const req = httpMock.expectOne( + `${apiUrl}/clients/${clientId}/agents/${agentId}/files/${filePath}?context=config`, + ); + expect(req.request.method).toBe('DELETE'); + expect(req.request.params.get('context')).toBe('config'); + req.flush(null); + }); }); describe('moveFileOrDirectory', () => { @@ -198,6 +283,24 @@ describe('FilesService', () => { req.flush(null); }); + it('should append context=config on move when requested', (done) => { + const sourcePath = 'a.txt'; + const moveDto: MoveFileDto = { + destination: 'b.txt', + }; + + service.moveFileOrDirectory(clientId, agentId, sourcePath, moveDto, 'config').subscribe(() => { + done(); + }); + + const req = httpMock.expectOne( + `${apiUrl}/clients/${clientId}/agents/${agentId}/files/${sourcePath}?context=config`, + ); + expect(req.request.method).toBe('PATCH'); + expect(req.request.params.get('context')).toBe('config'); + req.flush(null); + }); + it('should encode source path segments separately preserving forward slashes', (done) => { const sourcePath = 'tools/pAGENTZx.md'; const moveDto: MoveFileDto = { diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.ts index 2c5319c8..4a5c5715 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/services/files.service.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import type { CreateFileDto, FileContentDto, + FileManagerContext, FileNodeDto, ListDirectoryParams, MoveFileDto, @@ -48,9 +49,16 @@ export class FilesService { * @param filePath - The file path relative to /app * @returns Observable of file content (base64-encoded) */ - readFile(clientId: string, agentId: string, filePath: string): Observable { + readFile( + clientId: string, + agentId: string, + filePath: string, + context: FileManagerContext = 'app', + ): Observable { const encodedPath = this.encodePath(filePath); - return this.http.get(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`); + const url = `${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`; + const httpParams = context === 'config' ? new HttpParams().set('context', 'config') : undefined; + return this.http.get(url, httpParams ? { params: httpParams } : {}); } /** @@ -61,11 +69,19 @@ export class FilesService { * @param writeFileDto - The file content to write (base64-encoded) * @returns Observable of void */ - writeFile(clientId: string, agentId: string, filePath: string, writeFileDto: WriteFileDto): Observable { + writeFile( + clientId: string, + agentId: string, + filePath: string, + writeFileDto: WriteFileDto, + context: FileManagerContext = 'app', + ): Observable { const encodedPath = this.encodePath(filePath); + const httpParams = context === 'config' ? new HttpParams().set('context', 'config') : undefined; return this.http.put( `${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`, writeFileDto, + httpParams ? { params: httpParams } : {}, ); } @@ -81,6 +97,9 @@ export class FilesService { if (params?.path !== undefined) { httpParams = httpParams.set('path', params.path); } + if (params?.context === 'config') { + httpParams = httpParams.set('context', 'config'); + } return this.http.get(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/files`, { params: httpParams, @@ -100,11 +119,14 @@ export class FilesService { agentId: string, filePath: string, createFileDto: CreateFileDto, + context: FileManagerContext = 'app', ): Observable { const encodedPath = this.encodePath(filePath); + const httpParams = context === 'config' ? new HttpParams().set('context', 'config') : undefined; return this.http.post( `${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`, createFileDto, + httpParams ? { params: httpParams } : {}, ); } @@ -115,9 +137,18 @@ export class FilesService { * @param filePath - The file path relative to /app * @returns Observable of void */ - deleteFileOrDirectory(clientId: string, agentId: string, filePath: string): Observable { + deleteFileOrDirectory( + clientId: string, + agentId: string, + filePath: string, + context: FileManagerContext = 'app', + ): Observable { const encodedPath = this.encodePath(filePath); - return this.http.delete(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`); + const httpParams = context === 'config' ? new HttpParams().set('context', 'config') : undefined; + return this.http.delete( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`, + httpParams ? { params: httpParams } : {}, + ); } /** @@ -133,11 +164,14 @@ export class FilesService { agentId: string, sourcePath: string, moveFileDto: MoveFileDto, + context: FileManagerContext = 'app', ): Observable { const encodedPath = this.encodePath(sourcePath); + const httpParams = context === 'config' ? new HttpParams().set('context', 'config') : undefined; return this.http.patch( `${this.apiUrl}/clients/${clientId}/agents/${agentId}/files/${encodedPath}`, moveFileDto, + httpParams ? { params: httpParams } : {}, ); } } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.actions.ts index a2d59dc1..e5f8aa44 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.actions.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.actions.ts @@ -2,6 +2,7 @@ import { createAction, props } from '@ngrx/store'; import type { CreateFileDto, FileContentDto, + FileManagerContext, FileNodeDto, ListDirectoryParams, MoveFileDto, @@ -11,33 +12,45 @@ import type { // Read File Actions export const readFile = createAction( '[Files] Read File', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const readFileSuccess = createAction( '[Files] Read File Success', - props<{ clientId: string; agentId: string; filePath: string; content: FileContentDto }>(), + props<{ + clientId: string; + agentId: string; + filePath: string; + content: FileContentDto; + context?: FileManagerContext; + }>(), ); export const readFileFailure = createAction( '[Files] Read File Failure', - props<{ clientId: string; agentId: string; filePath: string; error: string }>(), + props<{ clientId: string; agentId: string; filePath: string; error: string; context?: FileManagerContext }>(), ); // Write File Actions export const writeFile = createAction( '[Files] Write File', - props<{ clientId: string; agentId: string; filePath: string; writeFileDto: WriteFileDto }>(), + props<{ + clientId: string; + agentId: string; + filePath: string; + writeFileDto: WriteFileDto; + context?: FileManagerContext; + }>(), ); export const writeFileSuccess = createAction( '[Files] Write File Success', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const writeFileFailure = createAction( '[Files] Write File Failure', - props<{ clientId: string; agentId: string; filePath: string; error: string }>(), + props<{ clientId: string; agentId: string; filePath: string; error: string; context?: FileManagerContext }>(), ); // List Directory Actions @@ -48,98 +61,137 @@ export const listDirectory = createAction( export const listDirectorySuccess = createAction( '[Files] List Directory Success', - props<{ clientId: string; agentId: string; directoryPath: string; files: FileNodeDto[] }>(), + props<{ + clientId: string; + agentId: string; + directoryPath: string; + files: FileNodeDto[]; + context?: FileManagerContext; + }>(), ); export const listDirectoryFailure = createAction( '[Files] List Directory Failure', - props<{ clientId: string; agentId: string; directoryPath: string; error: string }>(), + props<{ + clientId: string; + agentId: string; + directoryPath: string; + error: string; + context?: FileManagerContext; + }>(), ); // Create File/Directory Actions export const createFileOrDirectory = createAction( '[Files] Create File Or Directory', - props<{ clientId: string; agentId: string; filePath: string; createFileDto: CreateFileDto }>(), + props<{ + clientId: string; + agentId: string; + filePath: string; + createFileDto: CreateFileDto; + context?: FileManagerContext; + }>(), ); export const createFileOrDirectorySuccess = createAction( '[Files] Create File Or Directory Success', - props<{ clientId: string; agentId: string; filePath: string; fileType: 'file' | 'directory' }>(), + props<{ + clientId: string; + agentId: string; + filePath: string; + fileType: 'file' | 'directory'; + context?: FileManagerContext; + }>(), ); export const createFileOrDirectoryFailure = createAction( '[Files] Create File Or Directory Failure', - props<{ clientId: string; agentId: string; filePath: string; error: string }>(), + props<{ clientId: string; agentId: string; filePath: string; error: string; context?: FileManagerContext }>(), ); // Delete File/Directory Actions export const deleteFileOrDirectory = createAction( '[Files] Delete File Or Directory', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const deleteFileOrDirectorySuccess = createAction( '[Files] Delete File Or Directory Success', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const deleteFileOrDirectoryFailure = createAction( '[Files] Delete File Or Directory Failure', - props<{ clientId: string; agentId: string; filePath: string; error: string }>(), + props<{ clientId: string; agentId: string; filePath: string; error: string; context?: FileManagerContext }>(), ); // Move File/Directory Actions export const moveFileOrDirectory = createAction( '[Files] Move File Or Directory', - props<{ clientId: string; agentId: string; sourcePath: string; moveFileDto: MoveFileDto }>(), + props<{ + clientId: string; + agentId: string; + sourcePath: string; + moveFileDto: MoveFileDto; + context?: FileManagerContext; + }>(), ); export const moveFileOrDirectorySuccess = createAction( '[Files] Move File Or Directory Success', - props<{ clientId: string; agentId: string; sourcePath: string; destinationPath: string }>(), + props<{ + clientId: string; + agentId: string; + sourcePath: string; + destinationPath: string; + context?: FileManagerContext; + }>(), ); export const moveFileOrDirectoryFailure = createAction( '[Files] Move File Or Directory Failure', - props<{ clientId: string; agentId: string; sourcePath: string; error: string }>(), + props<{ clientId: string; agentId: string; sourcePath: string; error: string; context?: FileManagerContext }>(), ); // Clear file content from cache export const clearFileContent = createAction( '[Files] Clear File Content', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); // Clear directory listing from cache export const clearDirectoryListing = createAction( '[Files] Clear Directory Listing', - props<{ clientId: string; agentId: string; directoryPath: string }>(), + props<{ clientId: string; agentId: string; directoryPath: string; context?: FileManagerContext }>(), ); // Open Tabs Management Actions export const openFileTab = createAction( '[Files] Open File Tab', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const closeFileTab = createAction( '[Files] Close File Tab', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const pinFileTab = createAction( '[Files] Pin File Tab', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const unpinFileTab = createAction( '[Files] Unpin File Tab', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); export const moveTabToFront = createAction( '[Files] Move Tab To Front', - props<{ clientId: string; agentId: string; filePath: string }>(), + props<{ clientId: string; agentId: string; filePath: string; context?: FileManagerContext }>(), ); -export const clearOpenTabs = createAction('[Files] Clear Open Tabs', props<{ clientId: string; agentId: string }>()); +export const clearOpenTabs = createAction( + '[Files] Clear Open Tabs', + props<{ clientId: string; agentId: string; context?: FileManagerContext }>(), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.spec.ts index 769f112c..737b475f 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.spec.ts @@ -81,13 +81,20 @@ describe('FilesEffects', () => { it('should return readFileSuccess on success', (done) => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); - const outcome = readFileSuccess({ clientId, agentId, filePath, content: mockFileContent }); + const outcome = readFileSuccess({ + clientId, + agentId, + filePath, + content: mockFileContent, + context: 'app', + }); actions$ = of(action); filesService.readFile.mockReturnValue(of(mockFileContent)); readFile$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.readFile).toHaveBeenCalledWith(clientId, agentId, filePath, 'app'); done(); }); }); @@ -96,13 +103,35 @@ describe('FilesEffects', () => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); const error = new Error('Read failed'); - const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Read failed' }); + const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Read failed', context: 'app' }); actions$ = of(action); filesService.readFile.mockReturnValue(throwError(() => error)); readFile$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.readFile).toHaveBeenCalledWith(clientId, agentId, filePath, 'app'); + done(); + }); + }); + + it('should forward config context to FilesService and success action', (done) => { + const filePath = 'settings.json'; + const action = readFile({ clientId, agentId, filePath, context: 'config' }); + const outcome = readFileSuccess({ + clientId, + agentId, + filePath, + content: mockFileContent, + context: 'config', + }); + + actions$ = of(action); + filesService.readFile.mockReturnValue(of(mockFileContent)); + + readFile$(actions$, filesService).subscribe((result) => { + expect(result).toEqual(outcome); + expect(filesService.readFile).toHaveBeenCalledWith(clientId, agentId, filePath, 'config'); done(); }); }); @@ -116,13 +145,14 @@ describe('FilesEffects', () => { encoding: 'utf-8', }; const action = writeFile({ clientId, agentId, filePath, writeFileDto: writeDto }); - const outcome = writeFileSuccess({ clientId, agentId, filePath }); + const outcome = writeFileSuccess({ clientId, agentId, filePath, context: 'app' }); actions$ = of(action); filesService.writeFile.mockReturnValue(of(undefined)); writeFile$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.writeFile).toHaveBeenCalledWith(clientId, agentId, filePath, writeDto, 'app'); done(); }); }); @@ -134,13 +164,14 @@ describe('FilesEffects', () => { }; const action = writeFile({ clientId, agentId, filePath, writeFileDto: writeDto }); const error = new Error('Write failed'); - const outcome = writeFileFailure({ clientId, agentId, filePath, error: 'Write failed' }); + const outcome = writeFileFailure({ clientId, agentId, filePath, error: 'Write failed', context: 'app' }); actions$ = of(action); filesService.writeFile.mockReturnValue(throwError(() => error)); writeFile$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.writeFile).toHaveBeenCalledWith(clientId, agentId, filePath, writeDto, 'app'); done(); }); }); @@ -155,6 +186,7 @@ describe('FilesEffects', () => { agentId, directoryPath, files: mockFileNodes, + context: 'app', }); actions$ = of(action); @@ -174,6 +206,7 @@ describe('FilesEffects', () => { agentId, directoryPath, files: mockFileNodes, + context: 'app', }); actions$ = of(action); @@ -194,6 +227,7 @@ describe('FilesEffects', () => { agentId, directoryPath: '.', error: 'List failed', + context: 'app', }); actions$ = of(action); @@ -204,6 +238,27 @@ describe('FilesEffects', () => { done(); }); }); + + it('should forward config context for list directory', (done) => { + const directoryPath = '.'; + const action = listDirectory({ clientId, agentId, params: { context: 'config' } }); + const outcome = listDirectorySuccess({ + clientId, + agentId, + directoryPath, + files: mockFileNodes, + context: 'config', + }); + + actions$ = of(action); + filesService.listDirectory.mockReturnValue(of(mockFileNodes)); + + listDirectory$(actions$, filesService).subscribe((result) => { + expect(result).toEqual(outcome); + expect(filesService.listDirectory).toHaveBeenCalledWith(clientId, agentId, { context: 'config' }); + done(); + }); + }); }); describe('createFileOrDirectory$', () => { @@ -214,13 +269,20 @@ describe('FilesEffects', () => { content: Buffer.from('File content', 'utf-8').toString('base64'), }; const action = createFileOrDirectory({ clientId, agentId, filePath, createFileDto: createDto }); - const outcome = createFileOrDirectorySuccess({ clientId, agentId, filePath, fileType: 'file' }); + const outcome = createFileOrDirectorySuccess({ + clientId, + agentId, + filePath, + fileType: 'file', + context: 'app', + }); actions$ = of(action); filesService.createFileOrDirectory.mockReturnValue(of(undefined)); createFileOrDirectory$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.createFileOrDirectory).toHaveBeenCalledWith(clientId, agentId, filePath, createDto, 'app'); done(); }); }); @@ -232,7 +294,13 @@ describe('FilesEffects', () => { }; const action = createFileOrDirectory({ clientId, agentId, filePath, createFileDto: createDto }); const error = new Error('Create failed'); - const outcome = createFileOrDirectoryFailure({ clientId, agentId, filePath, error: 'Create failed' }); + const outcome = createFileOrDirectoryFailure({ + clientId, + agentId, + filePath, + error: 'Create failed', + context: 'app', + }); actions$ = of(action); filesService.createFileOrDirectory.mockReturnValue(throwError(() => error)); @@ -248,13 +316,14 @@ describe('FilesEffects', () => { it('should return deleteFileOrDirectorySuccess on success', (done) => { const filePath = 'file-to-delete.txt'; const action = deleteFileOrDirectory({ clientId, agentId, filePath }); - const outcome = deleteFileOrDirectorySuccess({ clientId, agentId, filePath }); + const outcome = deleteFileOrDirectorySuccess({ clientId, agentId, filePath, context: 'app' }); actions$ = of(action); filesService.deleteFileOrDirectory.mockReturnValue(of(undefined)); deleteFileOrDirectory$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.deleteFileOrDirectory).toHaveBeenCalledWith(clientId, agentId, filePath, 'app'); done(); }); }); @@ -263,7 +332,13 @@ describe('FilesEffects', () => { const filePath = 'file-to-delete.txt'; const action = deleteFileOrDirectory({ clientId, agentId, filePath }); const error = new Error('Delete failed'); - const outcome = deleteFileOrDirectoryFailure({ clientId, agentId, filePath, error: 'Delete failed' }); + const outcome = deleteFileOrDirectoryFailure({ + clientId, + agentId, + filePath, + error: 'Delete failed', + context: 'app', + }); actions$ = of(action); filesService.deleteFileOrDirectory.mockReturnValue(throwError(() => error)); @@ -287,6 +362,7 @@ describe('FilesEffects', () => { agentId, sourcePath, destinationPath: moveDto.destination, + context: 'app', }); actions$ = of(action); @@ -294,6 +370,7 @@ describe('FilesEffects', () => { moveFileOrDirectory$(actions$, filesService).subscribe((result) => { expect(result).toEqual(outcome); + expect(filesService.moveFileOrDirectory).toHaveBeenCalledWith(clientId, agentId, sourcePath, moveDto, 'app'); done(); }); }); @@ -305,7 +382,13 @@ describe('FilesEffects', () => { }; const action = moveFileOrDirectory({ clientId, agentId, sourcePath, moveFileDto: moveDto }); const error = new Error('Move failed'); - const outcome = moveFileOrDirectoryFailure({ clientId, agentId, sourcePath, error: 'Move failed' }); + const outcome = moveFileOrDirectoryFailure({ + clientId, + agentId, + sourcePath, + error: 'Move failed', + context: 'app', + }); actions$ = of(action); filesService.moveFileOrDirectory.mockReturnValue(throwError(() => error)); @@ -322,7 +405,7 @@ describe('FilesEffects', () => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); const error = new Error('Test error'); - const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Test error' }); + const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Test error', context: 'app' }); actions$ = of(action); filesService.readFile.mockReturnValue(throwError(() => error)); @@ -337,7 +420,7 @@ describe('FilesEffects', () => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); const error = 'String error'; - const outcome = readFileFailure({ clientId, agentId, filePath, error: 'String error' }); + const outcome = readFileFailure({ clientId, agentId, filePath, error: 'String error', context: 'app' }); actions$ = of(action); filesService.readFile.mockReturnValue(throwError(() => error)); @@ -352,7 +435,7 @@ describe('FilesEffects', () => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); const error = { message: 'Object error' }; - const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Object error' }); + const outcome = readFileFailure({ clientId, agentId, filePath, error: 'Object error', context: 'app' }); actions$ = of(action); filesService.readFile.mockReturnValue(throwError(() => error)); @@ -367,7 +450,13 @@ describe('FilesEffects', () => { const filePath = 'test-file.txt'; const action = readFile({ clientId, agentId, filePath }); const error = { unknown: 'property' }; - const outcome = readFileFailure({ clientId, agentId, filePath, error: 'An unexpected error occurred' }); + const outcome = readFileFailure({ + clientId, + agentId, + filePath, + error: 'An unexpected error occurred', + context: 'app', + }); actions$ = of(action); filesService.readFile.mockReturnValue(throwError(() => error)); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.ts index 4db0de32..f627c7e2 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.effects.ts @@ -43,12 +43,15 @@ export const readFile$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(readFile), - switchMap(({ clientId, agentId, filePath }) => - filesService.readFile(clientId, agentId, filePath).pipe( - map((content) => readFileSuccess({ clientId, agentId, filePath, content })), - catchError((error) => of(readFileFailure({ clientId, agentId, filePath, error: normalizeError(error) }))), - ), - ), + switchMap(({ clientId, agentId, filePath, context }) => { + const c = context ?? 'app'; + return filesService.readFile(clientId, agentId, filePath, c).pipe( + map((content) => readFileSuccess({ clientId, agentId, filePath, content, context: c })), + catchError((error) => + of(readFileFailure({ clientId, agentId, filePath, error: normalizeError(error), context: c })), + ), + ); + }), ); }, { functional: true }, @@ -58,12 +61,15 @@ export const writeFile$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(writeFile), - exhaustMap(({ clientId, agentId, filePath, writeFileDto }) => - filesService.writeFile(clientId, agentId, filePath, writeFileDto).pipe( - map(() => writeFileSuccess({ clientId, agentId, filePath })), - catchError((error) => of(writeFileFailure({ clientId, agentId, filePath, error: normalizeError(error) }))), - ), - ), + exhaustMap(({ clientId, agentId, filePath, writeFileDto, context }) => { + const c = context ?? 'app'; + return filesService.writeFile(clientId, agentId, filePath, writeFileDto, c).pipe( + map(() => writeFileSuccess({ clientId, agentId, filePath, context: c })), + catchError((error) => + of(writeFileFailure({ clientId, agentId, filePath, error: normalizeError(error), context: c })), + ), + ); + }), ); }, { functional: true }, @@ -73,21 +79,24 @@ export const listDirectory$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(listDirectory), - mergeMap(({ clientId, agentId, params }) => - filesService.listDirectory(clientId, agentId, params).pipe( - map((files) => listDirectorySuccess({ clientId, agentId, directoryPath: params?.path || '.', files })), + mergeMap(({ clientId, agentId, params }) => { + const directoryPath = params?.path || '.'; + const c = params?.context ?? 'app'; + return filesService.listDirectory(clientId, agentId, params).pipe( + map((files) => listDirectorySuccess({ clientId, agentId, directoryPath, files, context: c })), catchError((error) => of( listDirectoryFailure({ clientId, agentId, - directoryPath: params?.path || '.', + directoryPath, error: normalizeError(error), + context: c, }), ), ), - ), - ), + ); + }), ); }, { functional: true }, @@ -97,14 +106,23 @@ export const createFileOrDirectory$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(createFileOrDirectory), - exhaustMap(({ clientId, agentId, filePath, createFileDto }) => - filesService.createFileOrDirectory(clientId, agentId, filePath, createFileDto).pipe( - map(() => createFileOrDirectorySuccess({ clientId, agentId, filePath, fileType: createFileDto.type })), + exhaustMap(({ clientId, agentId, filePath, createFileDto, context }) => { + const c = context ?? 'app'; + return filesService.createFileOrDirectory(clientId, agentId, filePath, createFileDto, c).pipe( + map(() => + createFileOrDirectorySuccess({ + clientId, + agentId, + filePath, + fileType: createFileDto.type, + context: c, + }), + ), catchError((error) => - of(createFileOrDirectoryFailure({ clientId, agentId, filePath, error: normalizeError(error) })), + of(createFileOrDirectoryFailure({ clientId, agentId, filePath, error: normalizeError(error), context: c })), ), - ), - ), + ); + }), ); }, { functional: true }, @@ -114,14 +132,15 @@ export const deleteFileOrDirectory$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(deleteFileOrDirectory), - exhaustMap(({ clientId, agentId, filePath }) => - filesService.deleteFileOrDirectory(clientId, agentId, filePath).pipe( - map(() => deleteFileOrDirectorySuccess({ clientId, agentId, filePath })), + exhaustMap(({ clientId, agentId, filePath, context }) => { + const c = context ?? 'app'; + return filesService.deleteFileOrDirectory(clientId, agentId, filePath, c).pipe( + map(() => deleteFileOrDirectorySuccess({ clientId, agentId, filePath, context: c })), catchError((error) => - of(deleteFileOrDirectoryFailure({ clientId, agentId, filePath, error: normalizeError(error) })), + of(deleteFileOrDirectoryFailure({ clientId, agentId, filePath, error: normalizeError(error), context: c })), ), - ), - ), + ); + }), ); }, { functional: true }, @@ -131,21 +150,23 @@ export const moveFileOrDirectory$ = createEffect( (actions$ = inject(Actions), filesService = inject(FilesService)) => { return actions$.pipe( ofType(moveFileOrDirectory), - exhaustMap(({ clientId, agentId, sourcePath, moveFileDto }) => - filesService.moveFileOrDirectory(clientId, agentId, sourcePath, moveFileDto).pipe( + exhaustMap(({ clientId, agentId, sourcePath, moveFileDto, context }) => { + const c = context ?? 'app'; + return filesService.moveFileOrDirectory(clientId, agentId, sourcePath, moveFileDto, c).pipe( map(() => moveFileOrDirectorySuccess({ clientId, agentId, sourcePath, destinationPath: moveFileDto.destination, + context: c, }), ), catchError((error) => - of(moveFileOrDirectoryFailure({ clientId, agentId, sourcePath, error: normalizeError(error) })), + of(moveFileOrDirectoryFailure({ clientId, agentId, sourcePath, error: normalizeError(error), context: c })), ), - ), - ), + ); + }), ); }, { functional: true }, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.spec.ts index cf9689c7..d1918e3c 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.spec.ts @@ -204,6 +204,12 @@ describe('FilesFacade', () => { expect(store.dispatch).toHaveBeenCalledWith(readFile({ clientId, agentId, filePath })); }); + it('should dispatch readFile with config context when provided', () => { + facade.readFile(clientId, agentId, filePath, 'config'); + + expect(store.dispatch).toHaveBeenCalledWith(readFile({ clientId, agentId, filePath, context: 'config' })); + }); + it('should dispatch writeFile action', () => { const writeDto: WriteFileDto = { content: Buffer.from('New content', 'utf-8').toString('base64'), diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.ts index 260d7c46..fbbdcf6d 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.facade.ts @@ -35,12 +35,20 @@ import { import type { CreateFileDto, FileContentDto, + FileManagerContext, FileNodeDto, ListDirectoryParams, MoveFileDto, WriteFileDto, } from './files.types'; +function withOptionalFileContext( + payload: T, + context?: FileManagerContext, +): T | (T & { context: FileManagerContext }) { + return context === undefined ? payload : { ...payload, context }; +} + /** * Facade for file system state management. * Provides a clean API for components to interact with file system state @@ -60,8 +68,13 @@ export class FilesFacade { * @param filePath - The file path relative to /app * @returns Observable of file content or null if not loaded */ - getFileContent$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectFileContent(clientId, agentId, filePath)); + getFileContent$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectFileContent(clientId, agentId, filePath, context)); } /** @@ -71,8 +84,13 @@ export class FilesFacade { * @param directoryPath - The directory path relative to /app (defaults to '.') * @returns Observable of file nodes array or null if not loaded */ - getDirectoryListing$(clientId: string, agentId: string, directoryPath = '.'): Observable { - return this.store.select(selectDirectoryListing(clientId, agentId, directoryPath)); + getDirectoryListing$( + clientId: string, + agentId: string, + directoryPath = '.', + context?: FileManagerContext, + ): Observable { + return this.store.select(selectDirectoryListing(clientId, agentId, directoryPath, context)); } /** @@ -82,8 +100,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of loading state */ - isReadingFile$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectIsReadingFile(clientId, agentId, filePath)); + isReadingFile$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsReadingFile(clientId, agentId, filePath, context)); } /** @@ -93,8 +116,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of loading state */ - isWritingFile$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectIsWritingFile(clientId, agentId, filePath)); + isWritingFile$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsWritingFile(clientId, agentId, filePath, context)); } /** @@ -104,8 +132,13 @@ export class FilesFacade { * @param directoryPath - The directory path * @returns Observable of loading state */ - isListingDirectory$(clientId: string, agentId: string, directoryPath = '.'): Observable { - return this.store.select(selectIsListingDirectory(clientId, agentId, directoryPath)); + isListingDirectory$( + clientId: string, + agentId: string, + directoryPath = '.', + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsListingDirectory(clientId, agentId, directoryPath, context)); } /** @@ -115,8 +148,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of loading state */ - isCreatingFile$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectIsCreatingFile(clientId, agentId, filePath)); + isCreatingFile$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsCreatingFile(clientId, agentId, filePath, context)); } /** @@ -126,8 +164,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of loading state */ - isDeletingFile$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectIsDeletingFile(clientId, agentId, filePath)); + isDeletingFile$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsDeletingFile(clientId, agentId, filePath, context)); } /** @@ -137,8 +180,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of loading state */ - isMovingFile$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectIsMovingFile(clientId, agentId, filePath)); + isMovingFile$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectIsMovingFile(clientId, agentId, filePath, context)); } /** @@ -148,8 +196,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of combined loading state */ - isFileOperationLoading$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectFileOperationLoading(clientId, agentId, filePath)); + isFileOperationLoading$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectFileOperationLoading(clientId, agentId, filePath, context)); } /** @@ -159,8 +212,13 @@ export class FilesFacade { * @param directoryPath - The directory path * @returns Observable of loading state */ - isDirectoryOperationLoading$(clientId: string, agentId: string, directoryPath = '.'): Observable { - return this.store.select(selectDirectoryOperationLoading(clientId, agentId, directoryPath)); + isDirectoryOperationLoading$( + clientId: string, + agentId: string, + directoryPath = '.', + context?: FileManagerContext, + ): Observable { + return this.store.select(selectDirectoryOperationLoading(clientId, agentId, directoryPath, context)); } /** @@ -170,8 +228,13 @@ export class FilesFacade { * @param filePath - The file path * @returns Observable of error message or null */ - getFileError$(clientId: string, agentId: string, filePath: string): Observable { - return this.store.select(selectFileError(clientId, agentId, filePath)); + getFileError$( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, + ): Observable { + return this.store.select(selectFileError(clientId, agentId, filePath, context)); } /** @@ -180,8 +243,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path relative to /app */ - readFile(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(readFile({ clientId, agentId, filePath })); + readFile(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(readFile(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -191,8 +254,14 @@ export class FilesFacade { * @param filePath - The file path relative to /app * @param writeFileDto - The file content to write (base64-encoded) */ - writeFile(clientId: string, agentId: string, filePath: string, writeFileDto: WriteFileDto): void { - this.store.dispatch(writeFile({ clientId, agentId, filePath, writeFileDto })); + writeFile( + clientId: string, + agentId: string, + filePath: string, + writeFileDto: WriteFileDto, + context?: FileManagerContext, + ): void { + this.store.dispatch(writeFile(withOptionalFileContext({ clientId, agentId, filePath, writeFileDto }, context))); } /** @@ -212,8 +281,16 @@ export class FilesFacade { * @param filePath - The file path relative to /app * @param createFileDto - The file/directory creation data */ - createFileOrDirectory(clientId: string, agentId: string, filePath: string, createFileDto: CreateFileDto): void { - this.store.dispatch(createFileOrDirectory({ clientId, agentId, filePath, createFileDto })); + createFileOrDirectory( + clientId: string, + agentId: string, + filePath: string, + createFileDto: CreateFileDto, + context?: FileManagerContext, + ): void { + this.store.dispatch( + createFileOrDirectory(withOptionalFileContext({ clientId, agentId, filePath, createFileDto }, context)), + ); } /** @@ -222,8 +299,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path relative to /app */ - deleteFileOrDirectory(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(deleteFileOrDirectory({ clientId, agentId, filePath })); + deleteFileOrDirectory(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(deleteFileOrDirectory(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -233,8 +310,16 @@ export class FilesFacade { * @param sourcePath - The source file path relative to /app * @param moveFileDto - The move operation data (destination path) */ - moveFileOrDirectory(clientId: string, agentId: string, sourcePath: string, moveFileDto: MoveFileDto): void { - this.store.dispatch(moveFileOrDirectory({ clientId, agentId, sourcePath, moveFileDto })); + moveFileOrDirectory( + clientId: string, + agentId: string, + sourcePath: string, + moveFileDto: MoveFileDto, + context?: FileManagerContext, + ): void { + this.store.dispatch( + moveFileOrDirectory(withOptionalFileContext({ clientId, agentId, sourcePath, moveFileDto }, context)), + ); } /** @@ -243,8 +328,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - clearFileContent(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(clearFileContent({ clientId, agentId, filePath })); + clearFileContent(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(clearFileContent(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -253,8 +338,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param directoryPath - The directory path */ - clearDirectoryListing(clientId: string, agentId: string, directoryPath: string): void { - this.store.dispatch(clearDirectoryListing({ clientId, agentId, directoryPath })); + clearDirectoryListing(clientId: string, agentId: string, directoryPath: string, context?: FileManagerContext): void { + this.store.dispatch(clearDirectoryListing(withOptionalFileContext({ clientId, agentId, directoryPath }, context))); } /** @@ -263,8 +348,8 @@ export class FilesFacade { * @param agentId - The agent ID * @returns Observable of open tabs array */ - getOpenTabs$(clientId: string, agentId: string): Observable { - return this.store.select(selectOpenTabsForClientAgent(clientId, agentId)); + getOpenTabs$(clientId: string, agentId: string, context?: FileManagerContext): Observable { + return this.store.select(selectOpenTabsForClientAgent(clientId, agentId, context)); } /** @@ -273,8 +358,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - openFileTab(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(openFileTab({ clientId, agentId, filePath })); + openFileTab(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(openFileTab(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -283,8 +368,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - closeFileTab(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(closeFileTab({ clientId, agentId, filePath })); + closeFileTab(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(closeFileTab(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -293,8 +378,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - pinFileTab(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(pinFileTab({ clientId, agentId, filePath })); + pinFileTab(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(pinFileTab(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -303,8 +388,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - unpinFileTab(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(unpinFileTab({ clientId, agentId, filePath })); + unpinFileTab(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(unpinFileTab(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -313,8 +398,8 @@ export class FilesFacade { * @param agentId - The agent ID * @param filePath - The file path */ - moveTabToFront(clientId: string, agentId: string, filePath: string): void { - this.store.dispatch(moveTabToFront({ clientId, agentId, filePath })); + moveTabToFront(clientId: string, agentId: string, filePath: string, context?: FileManagerContext): void { + this.store.dispatch(moveTabToFront(withOptionalFileContext({ clientId, agentId, filePath }, context))); } /** @@ -322,7 +407,7 @@ export class FilesFacade { * @param clientId - The client ID * @param agentId - The agent ID */ - clearOpenTabs(clientId: string, agentId: string): void { - this.store.dispatch(clearOpenTabs({ clientId, agentId })); + clearOpenTabs(clientId: string, agentId: string, context?: FileManagerContext): void { + this.store.dispatch(clearOpenTabs(withOptionalFileContext({ clientId, agentId }, context))); } } diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.spec.ts index b323966a..d9e833cb 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.spec.ts @@ -68,12 +68,12 @@ describe('filesReducer', () => { it('should set reading to true and clear error', () => { const state: FilesState = { ...initialFilesState, - errors: { [`${clientId}:${agentId}:${filePath}`]: 'Previous error' }, + errors: { [`${clientId}:${agentId}:app:${filePath}`]: 'Previous error' }, }; const newState = filesReducer(state, readFile({ clientId, agentId, filePath })); - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; expect(newState.reading[key]).toBe(true); expect(newState.errors[key]).toBeNull(); }); @@ -83,12 +83,12 @@ describe('filesReducer', () => { it('should store file content and set reading to false', () => { const state: FilesState = { ...initialFilesState, - reading: { [`${clientId}:${agentId}:${filePath}`]: true }, + reading: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, readFileSuccess({ clientId, agentId, filePath, content: mockFileContent })); - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; expect(newState.fileContents[key]).toEqual(mockFileContent); expect(newState.reading[key]).toBe(false); expect(newState.errors[key]).toBeNull(); @@ -99,12 +99,12 @@ describe('filesReducer', () => { it('should set error and set reading to false', () => { const state: FilesState = { ...initialFilesState, - reading: { [`${clientId}:${agentId}:${filePath}`]: true }, + reading: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, readFileFailure({ clientId, agentId, filePath, error: 'Read failed' })); - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; expect(newState.errors[key]).toBe('Read failed'); expect(newState.reading[key]).toBe(false); }); @@ -114,7 +114,7 @@ describe('filesReducer', () => { it('should set writing to true and clear error', () => { const state: FilesState = { ...initialFilesState, - errors: { [`${clientId}:${agentId}:${filePath}`]: 'Previous error' }, + errors: { [`${clientId}:${agentId}:app:${filePath}`]: 'Previous error' }, }; const newState = filesReducer( @@ -122,7 +122,7 @@ describe('filesReducer', () => { writeFile({ clientId, agentId, filePath, writeFileDto: { content: 'base64' } }), ); - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; expect(newState.writing[key]).toBe(true); expect(newState.errors[key]).toBeNull(); }); @@ -130,7 +130,7 @@ describe('filesReducer', () => { describe('writeFileSuccess', () => { it('should invalidate cached content and set writing to false', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, fileContents: { [key]: mockFileContent }, @@ -147,7 +147,7 @@ describe('filesReducer', () => { describe('writeFileFailure', () => { it('should set error and set writing to false', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, writing: { [key]: true }, @@ -162,7 +162,7 @@ describe('filesReducer', () => { describe('listDirectory', () => { it('should set listing to true and clear error', () => { - const key = `${clientId}:${agentId}:${directoryPath}`; + const key = `${clientId}:${agentId}:app:${directoryPath}`; const state: FilesState = { ...initialFilesState, errors: { [key]: 'Previous error' }, @@ -176,7 +176,7 @@ describe('filesReducer', () => { it('should use provided path parameter', () => { const customPath = 'subdirectory'; - const key = `${clientId}:${agentId}:${customPath}`; + const key = `${clientId}:${agentId}:app:${customPath}`; const newState = filesReducer( initialFilesState, @@ -189,7 +189,7 @@ describe('filesReducer', () => { describe('listDirectorySuccess', () => { it('should store directory listing and set listing to false', () => { - const key = `${clientId}:${agentId}:${directoryPath}`; + const key = `${clientId}:${agentId}:app:${directoryPath}`; const state: FilesState = { ...initialFilesState, listing: { [key]: true }, @@ -208,7 +208,7 @@ describe('filesReducer', () => { describe('listDirectoryFailure', () => { it('should set error and set listing to false', () => { - const key = `${clientId}:${agentId}:${directoryPath}`; + const key = `${clientId}:${agentId}:app:${directoryPath}`; const state: FilesState = { ...initialFilesState, listing: { [key]: true }, @@ -226,7 +226,7 @@ describe('filesReducer', () => { describe('createFileOrDirectory', () => { it('should set creating to true and clear error', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, errors: { [key]: 'Previous error' }, @@ -244,9 +244,9 @@ describe('filesReducer', () => { describe('createFileOrDirectorySuccess', () => { it('should invalidate parent directory listing and set creating to false', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const parentPath = '.'; - const parentKey = `${clientId}:${agentId}:${parentPath}`; + const parentKey = `${clientId}:${agentId}:app:${parentPath}`; const state: FilesState = { ...initialFilesState, directoryListings: { [parentKey]: mockFileNodes }, @@ -266,7 +266,7 @@ describe('filesReducer', () => { describe('createFileOrDirectoryFailure', () => { it('should set error and set creating to false', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, creating: { [key]: true }, @@ -284,7 +284,7 @@ describe('filesReducer', () => { describe('deleteFileOrDirectory', () => { it('should set deleting to true and clear error', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, errors: { [key]: 'Previous error' }, @@ -299,9 +299,9 @@ describe('filesReducer', () => { describe('deleteFileOrDirectorySuccess', () => { it('should remove from cache and invalidate parent directory listing', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const parentPath = '.'; - const parentKey = `${clientId}:${agentId}:${parentPath}`; + const parentKey = `${clientId}:${agentId}:app:${parentPath}`; const state: FilesState = { ...initialFilesState, fileContents: { [key]: mockFileContent }, @@ -321,7 +321,7 @@ describe('filesReducer', () => { describe('deleteFileOrDirectoryFailure', () => { it('should set error and set deleting to false', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, deleting: { [key]: true }, @@ -340,7 +340,7 @@ describe('filesReducer', () => { describe('moveFileOrDirectory', () => { it('should set moving to true and clear error', () => { const sourcePath = 'source-file.txt'; - const key = `${clientId}:${agentId}:${sourcePath}`; + const key = `${clientId}:${agentId}:app:${sourcePath}`; const state: FilesState = { ...initialFilesState, errors: { [key]: 'Previous error' }, @@ -365,10 +365,10 @@ describe('filesReducer', () => { it('should move file content to destination and invalidate parent directory listings', () => { const sourcePath = 'source-file.txt'; const destinationPath = 'dest-file.txt'; - const sourceKey = `${clientId}:${agentId}:${sourcePath}`; - const destinationKey = `${clientId}:${agentId}:${destinationPath}`; + const sourceKey = `${clientId}:${agentId}:app:${sourcePath}`; + const destinationKey = `${clientId}:${agentId}:app:${destinationPath}`; const sourceParentPath = '.'; - const sourceParentKey = `${clientId}:${agentId}:${sourceParentPath}`; + const sourceParentKey = `${clientId}:${agentId}:app:${sourceParentPath}`; const state: FilesState = { ...initialFilesState, fileContents: { [sourceKey]: mockFileContent }, @@ -391,8 +391,8 @@ describe('filesReducer', () => { it('should update open tabs when file is moved', () => { const sourcePath = 'source-file.txt'; const destinationPath = 'dest-file.txt'; - const sourceKey = `${clientId}:${agentId}:${sourcePath}`; - const clientAgentKey = `${clientId}:${agentId}`; + const sourceKey = `${clientId}:${agentId}:app:${sourcePath}`; + const clientAgentKey = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -412,10 +412,10 @@ describe('filesReducer', () => { it('should handle move when file content does not exist in cache', () => { const sourcePath = 'source-file.txt'; const destinationPath = 'dest-file.txt'; - const sourceKey = `${clientId}:${agentId}:${sourcePath}`; - const destinationKey = `${clientId}:${agentId}:${destinationPath}`; + const sourceKey = `${clientId}:${agentId}:app:${sourcePath}`; + const destinationKey = `${clientId}:${agentId}:app:${destinationPath}`; const sourceParentPath = '.'; - const sourceParentKey = `${clientId}:${agentId}:${sourceParentPath}`; + const sourceParentKey = `${clientId}:${agentId}:app:${sourceParentPath}`; const state: FilesState = { ...initialFilesState, directoryListings: { [sourceParentKey]: mockFileNodes }, @@ -437,7 +437,7 @@ describe('filesReducer', () => { describe('moveFileOrDirectoryFailure', () => { it('should set error and set moving to false', () => { const sourcePath = 'source-file.txt'; - const key = `${clientId}:${agentId}:${sourcePath}`; + const key = `${clientId}:${agentId}:app:${sourcePath}`; const state: FilesState = { ...initialFilesState, moving: { [key]: true }, @@ -455,7 +455,7 @@ describe('filesReducer', () => { describe('clearFileContent', () => { it('should remove file content from cache', () => { - const key = `${clientId}:${agentId}:${filePath}`; + const key = `${clientId}:${agentId}:app:${filePath}`; const state: FilesState = { ...initialFilesState, fileContents: { [key]: mockFileContent }, @@ -469,7 +469,7 @@ describe('filesReducer', () => { describe('clearDirectoryListing', () => { it('should remove directory listing from cache', () => { - const key = `${clientId}:${agentId}:${directoryPath}`; + const key = `${clientId}:${agentId}:app:${directoryPath}`; const state: FilesState = { ...initialFilesState, directoryListings: { [key]: mockFileNodes }, @@ -486,8 +486,8 @@ describe('filesReducer', () => { const clientId2 = 'client-2'; const agentId2 = 'agent-2'; const filePath2 = 'other-file.txt'; - const key1 = `${clientId}:${agentId}:${filePath}`; - const key2 = `${clientId2}:${agentId2}:${filePath2}`; + const key1 = `${clientId}:${agentId}:app:${filePath}`; + const key2 = `${clientId2}:${agentId2}:app:${filePath2}`; let state = initialFilesState; @@ -512,13 +512,13 @@ describe('filesReducer', () => { it('should add a new tab when opening a file', () => { const newState = filesReducer(initialFilesState, openFileTab({ clientId, agentId, filePath })); - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; expect(newState.openTabs[key]).toHaveLength(1); expect(newState.openTabs[key][0]).toEqual({ filePath, pinned: false }); }); it('should not add duplicate tabs if tab is already pinned', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: true }] }, @@ -532,7 +532,7 @@ describe('filesReducer', () => { }); it('should replace unpinned tab when opening the same file again', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, @@ -547,7 +547,7 @@ describe('filesReducer', () => { it('should remove unpinned tabs when opening a new file', () => { const filePath2 = 'other-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; let state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, @@ -563,7 +563,7 @@ describe('filesReducer', () => { it('should keep pinned tabs when opening a new file', () => { const filePath2 = 'other-file.txt'; const filePath3 = 'third-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; let state: FilesState = { ...initialFilesState, openTabs: { @@ -586,7 +586,7 @@ describe('filesReducer', () => { describe('closeFileTab', () => { it('should remove a tab when closing', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, @@ -599,7 +599,7 @@ describe('filesReducer', () => { it('should only remove the specified tab', () => { const filePath2 = 'other-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -617,7 +617,7 @@ describe('filesReducer', () => { }); it('should not change state when closing a tab that does not exist', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const nonExistentPath = 'non-existent.txt'; const state: FilesState = { ...initialFilesState, @@ -631,7 +631,7 @@ describe('filesReducer', () => { }); it('should handle closing when no tabs exist', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: {}, @@ -646,7 +646,7 @@ describe('filesReducer', () => { describe('pinFileTab', () => { it('should pin a tab', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, @@ -659,7 +659,7 @@ describe('filesReducer', () => { it('should only pin the specified tab', () => { const filePath2 = 'other-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -677,7 +677,7 @@ describe('filesReducer', () => { }); it('should not change state when pinning a tab that does not exist', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const nonExistentPath = 'non-existent.txt'; const state: FilesState = { ...initialFilesState, @@ -692,7 +692,7 @@ describe('filesReducer', () => { }); it('should not change state when pinning an already pinned tab', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: true }] }, @@ -706,7 +706,7 @@ describe('filesReducer', () => { describe('unpinFileTab', () => { it('should unpin a tab', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: true }] }, @@ -719,7 +719,7 @@ describe('filesReducer', () => { it('should only unpin the specified tab', () => { const filePath2 = 'other-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -737,7 +737,7 @@ describe('filesReducer', () => { }); it('should not change state when unpinning a tab that does not exist', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const nonExistentPath = 'non-existent.txt'; const state: FilesState = { ...initialFilesState, @@ -752,7 +752,7 @@ describe('filesReducer', () => { }); it('should not change state when unpinning an already unpinned tab', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, @@ -768,7 +768,7 @@ describe('filesReducer', () => { it('should move a tab to the front of the tabs list', () => { const filePath2 = 'second-file.txt'; const filePath3 = 'third-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -791,7 +791,7 @@ describe('filesReducer', () => { it('should not change state if tab is already at front', () => { const filePath2 = 'second-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -813,7 +813,7 @@ describe('filesReducer', () => { it('should not change state if tab is not found', () => { const filePath2 = 'second-file.txt'; const nonExistentPath = 'non-existent.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -834,7 +834,7 @@ describe('filesReducer', () => { it('should preserve tab properties when moving to front', () => { const filePath2 = 'second-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -858,8 +858,8 @@ describe('filesReducer', () => { const clientId2 = 'client-2'; const agentId2 = 'agent-2'; const filePath2 = 'second-file.txt'; - const key1 = `${clientId}:${agentId}`; - const key2 = `${clientId2}:${agentId2}`; + const key1 = `${clientId}:${agentId}:app`; + const key2 = `${clientId2}:${agentId2}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -890,7 +890,7 @@ describe('filesReducer', () => { describe('clearOpenTabs', () => { it('should clear all tabs for a client/agent', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -909,8 +909,8 @@ describe('filesReducer', () => { it('should only clear tabs for the specified client/agent', () => { const clientId2 = 'client-2'; const agentId2 = 'agent-2'; - const key1 = `${clientId}:${agentId}`; - const key2 = `${clientId2}:${agentId2}`; + const key1 = `${clientId}:${agentId}:app`; + const key2 = `${clientId2}:${agentId2}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -928,11 +928,11 @@ describe('filesReducer', () => { describe('writeFileSuccess pins tab', () => { it('should pin the tab when a file is saved', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: false }] }, - writing: { [`${clientId}:${agentId}:${filePath}`]: true }, + writing: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, writeFileSuccess({ clientId, agentId, filePath })); @@ -941,11 +941,11 @@ describe('filesReducer', () => { }); it('should keep tab pinned if already pinned when file is saved', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { [key]: [{ filePath, pinned: true }] }, - writing: { [`${clientId}:${agentId}:${filePath}`]: true }, + writing: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, writeFileSuccess({ clientId, agentId, filePath })); @@ -954,11 +954,11 @@ describe('filesReducer', () => { }); it('should create a pinned tab if tab does not exist when file is saved', () => { - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: {}, - writing: { [`${clientId}:${agentId}:${filePath}`]: true }, + writing: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, writeFileSuccess({ clientId, agentId, filePath })); @@ -970,7 +970,7 @@ describe('filesReducer', () => { it('should preserve other tabs when pinning a saved file', () => { const filePath2 = 'other-file.txt'; - const key = `${clientId}:${agentId}`; + const key = `${clientId}:${agentId}:app`; const state: FilesState = { ...initialFilesState, openTabs: { @@ -979,7 +979,7 @@ describe('filesReducer', () => { { filePath: filePath2, pinned: true }, ], }, - writing: { [`${clientId}:${agentId}:${filePath}`]: true }, + writing: { [`${clientId}:${agentId}:app:${filePath}`]: true }, }; const newState = filesReducer(state, writeFileSuccess({ clientId, agentId, filePath })); @@ -997,8 +997,8 @@ describe('filesReducer', () => { const clientId2 = 'client-2'; const agentId2 = 'agent-2'; const filePath2 = 'other-file.txt'; - const key1 = `${clientId}:${agentId}`; - const key2 = `${clientId2}:${agentId2}`; + const key1 = `${clientId}:${agentId}:app`; + const key2 = `${clientId2}:${agentId2}:app`; let state = initialFilesState; @@ -1011,4 +1011,26 @@ describe('filesReducer', () => { expect(state.openTabs[key2][0].filePath).toBe(filePath2); }); }); + + describe('file manager context', () => { + it('should keep app and config file content caches independent', () => { + const appKey = `${clientId}:${agentId}:app:${filePath}`; + const configContent: FileContentDto = { + content: Buffer.from('config', 'utf-8').toString('base64'), + encoding: 'utf-8', + }; + + let state = filesReducer( + initialFilesState, + readFileSuccess({ clientId, agentId, filePath, content: mockFileContent, context: 'app' }), + ); + state = filesReducer( + state, + readFileSuccess({ clientId, agentId, filePath, content: configContent, context: 'config' }), + ); + + expect(state.fileContents[appKey]).toEqual(mockFileContent); + expect(state.fileContents[`${clientId}:${agentId}:config:${filePath}`]).toEqual(configContent); + }); + }); }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.ts index 74d33648..8946ac92 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.reducer.ts @@ -27,7 +27,7 @@ import { writeFileFailure, writeFileSuccess, } from './files.actions'; -import type { FileContentDto, FileNodeDto } from './files.types'; +import type { FileContentDto, FileManagerContext, FileNodeDto } from './files.types'; export interface OpenTab { filePath: string; @@ -65,33 +65,39 @@ export const initialFilesState: FilesState = { openTabs: {}, }; +function resolveFileContext(context?: FileManagerContext): FileManagerContext { + return context ?? 'app'; +} + /** - * Generate a key for file operations (clientId:agentId:path) + * Generate a key for file operations (clientId:agentId:context:path) */ -function getFileKey(clientId: string, agentId: string, path: string): string { - return `${clientId}:${agentId}:${path}`; +function getFileKey(clientId: string, agentId: string, path: string, context?: FileManagerContext): string { + const c = resolveFileContext(context); + return `${clientId}:${agentId}:${c}:${path}`; } /** - * Generate a key for client/agent operations (clientId:agentId) + * Generate a key for client/agent + context (tabs and grouped state) */ -function getClientAgentKey(clientId: string, agentId: string): string { - return `${clientId}:${agentId}`; +function getClientAgentContextKey(clientId: string, agentId: string, context?: FileManagerContext): string { + const c = resolveFileContext(context); + return `${clientId}:${agentId}:${c}`; } export const filesReducer = createReducer( initialFilesState, // Read File - on(readFile, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(readFile, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, reading: { ...state.reading, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(readFileSuccess, (state, { clientId, agentId, filePath, content }) => { - const key = getFileKey(clientId, agentId, filePath); + on(readFileSuccess, (state, { clientId, agentId, filePath, content, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, fileContents: { ...state.fileContents, [key]: content }, @@ -99,8 +105,8 @@ export const filesReducer = createReducer( errors: { ...state.errors, [key]: null }, }; }), - on(readFileFailure, (state, { clientId, agentId, filePath, error }) => { - const key = getFileKey(clientId, agentId, filePath); + on(readFileFailure, (state, { clientId, agentId, filePath, error, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, reading: { ...state.reading, [key]: false }, @@ -108,17 +114,17 @@ export const filesReducer = createReducer( }; }), // Write File - on(writeFile, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(writeFile, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, writing: { ...state.writing, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(writeFileSuccess, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); - const clientAgentKey = getClientAgentKey(clientId, agentId); + on(writeFileSuccess, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); + const clientAgentKey = getClientAgentContextKey(clientId, agentId, context); // Invalidate cached content after write const { [key]: removed, ...fileContents } = state.fileContents; // Pin the tab when file is saved (create tab if it doesn't exist) @@ -143,8 +149,8 @@ export const filesReducer = createReducer( }, }; }), - on(writeFileFailure, (state, { clientId, agentId, filePath, error }) => { - const key = getFileKey(clientId, agentId, filePath); + on(writeFileFailure, (state, { clientId, agentId, filePath, error, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, writing: { ...state.writing, [key]: false }, @@ -154,15 +160,15 @@ export const filesReducer = createReducer( // List Directory on(listDirectory, (state, { clientId, agentId, params }) => { const directoryPath = params?.path || '.'; - const key = getFileKey(clientId, agentId, directoryPath); + const key = getFileKey(clientId, agentId, directoryPath, params?.context); return { ...state, listing: { ...state.listing, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(listDirectorySuccess, (state, { clientId, agentId, directoryPath, files }) => { - const key = getFileKey(clientId, agentId, directoryPath); + on(listDirectorySuccess, (state, { clientId, agentId, directoryPath, files, context }) => { + const key = getFileKey(clientId, agentId, directoryPath, context); return { ...state, directoryListings: { ...state.directoryListings, [key]: files }, @@ -170,8 +176,8 @@ export const filesReducer = createReducer( errors: { ...state.errors, [key]: null }, }; }), - on(listDirectoryFailure, (state, { clientId, agentId, directoryPath, error }) => { - const key = getFileKey(clientId, agentId, directoryPath); + on(listDirectoryFailure, (state, { clientId, agentId, directoryPath, error, context }) => { + const key = getFileKey(clientId, agentId, directoryPath, context); return { ...state, listing: { ...state.listing, [key]: false }, @@ -179,19 +185,19 @@ export const filesReducer = createReducer( }; }), // Create File/Directory - on(createFileOrDirectory, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(createFileOrDirectory, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, creating: { ...state.creating, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(createFileOrDirectorySuccess, (state, { clientId, agentId, filePath, fileType }) => { - const key = getFileKey(clientId, agentId, filePath); + on(createFileOrDirectorySuccess, (state, { clientId, agentId, filePath, fileType, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); // Invalidate parent directory listing const parentPath = filePath.split('/').slice(0, -1).join('/') || '.'; - const parentKey = getFileKey(clientId, agentId, parentPath); + const parentKey = getFileKey(clientId, agentId, parentPath, context); const { [parentKey]: removed, ...directoryListings } = state.directoryListings; return { ...state, @@ -200,8 +206,8 @@ export const filesReducer = createReducer( errors: { ...state.errors, [key]: null }, }; }), - on(createFileOrDirectoryFailure, (state, { clientId, agentId, filePath, error }) => { - const key = getFileKey(clientId, agentId, filePath); + on(createFileOrDirectoryFailure, (state, { clientId, agentId, filePath, error, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, creating: { ...state.creating, [key]: false }, @@ -209,22 +215,22 @@ export const filesReducer = createReducer( }; }), // Delete File/Directory - on(deleteFileOrDirectory, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(deleteFileOrDirectory, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, deleting: { ...state.deleting, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(deleteFileOrDirectorySuccess, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(deleteFileOrDirectorySuccess, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); // Remove from cache const { [key]: removedContent, ...fileContents } = state.fileContents; const { [key]: removedListing, ...directoryListings } = state.directoryListings; // Invalidate parent directory listing const parentPath = filePath.split('/').slice(0, -1).join('/') || '.'; - const parentKey = getFileKey(clientId, agentId, parentPath); + const parentKey = getFileKey(clientId, agentId, parentPath, context); const { [parentKey]: removedParent, ...remainingListings } = directoryListings; return { ...state, @@ -234,8 +240,8 @@ export const filesReducer = createReducer( errors: { ...state.errors, [key]: null }, }; }), - on(deleteFileOrDirectoryFailure, (state, { clientId, agentId, filePath, error }) => { - const key = getFileKey(clientId, agentId, filePath); + on(deleteFileOrDirectoryFailure, (state, { clientId, agentId, filePath, error, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); return { ...state, deleting: { ...state.deleting, [key]: false }, @@ -243,17 +249,17 @@ export const filesReducer = createReducer( }; }), // Move File/Directory - on(moveFileOrDirectory, (state, { clientId, agentId, sourcePath }) => { - const key = getFileKey(clientId, agentId, sourcePath); + on(moveFileOrDirectory, (state, { clientId, agentId, sourcePath, context }) => { + const key = getFileKey(clientId, agentId, sourcePath, context); return { ...state, moving: { ...state.moving, [key]: true }, errors: { ...state.errors, [key]: null }, }; }), - on(moveFileOrDirectorySuccess, (state, { clientId, agentId, sourcePath, destinationPath }) => { - const sourceKey = getFileKey(clientId, agentId, sourcePath); - const destinationKey = getFileKey(clientId, agentId, destinationPath); + on(moveFileOrDirectorySuccess, (state, { clientId, agentId, sourcePath, destinationPath, context }) => { + const sourceKey = getFileKey(clientId, agentId, sourcePath, context); + const destinationKey = getFileKey(clientId, agentId, destinationPath, context); // Remove source file content from cache const { [sourceKey]: removedSourceContent, ...fileContents } = state.fileContents; // Move file content to destination if it exists @@ -262,16 +268,16 @@ export const filesReducer = createReducer( : fileContents; // Remove source and destination directory listings (invalidate cache) const sourceParentPath = sourcePath.split('/').slice(0, -1).join('/') || '.'; - const sourceParentKey = getFileKey(clientId, agentId, sourceParentPath); + const sourceParentKey = getFileKey(clientId, agentId, sourceParentPath, context); const destinationParentPath = destinationPath.split('/').slice(0, -1).join('/') || '.'; - const destinationParentKey = getFileKey(clientId, agentId, destinationParentPath); + const destinationParentKey = getFileKey(clientId, agentId, destinationParentPath, context); const { [sourceParentKey]: removedSourceParent, [destinationParentKey]: removedDestParent, ...directoryListings } = state.directoryListings; // Update open tabs if the moved file is in a tab - const clientAgentKey = getClientAgentKey(clientId, agentId); + const clientAgentKey = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[clientAgentKey] || []; const updatedTabs = currentTabs.map((tab) => tab.filePath === sourcePath ? { ...tab, filePath: destinationPath } : tab, @@ -288,8 +294,8 @@ export const filesReducer = createReducer( }, }; }), - on(moveFileOrDirectoryFailure, (state, { clientId, agentId, sourcePath, error }) => { - const key = getFileKey(clientId, agentId, sourcePath); + on(moveFileOrDirectoryFailure, (state, { clientId, agentId, sourcePath, error, context }) => { + const key = getFileKey(clientId, agentId, sourcePath, context); return { ...state, moving: { ...state.moving, [key]: false }, @@ -297,8 +303,8 @@ export const filesReducer = createReducer( }; }), // Clear File Content - on(clearFileContent, (state, { clientId, agentId, filePath }) => { - const key = getFileKey(clientId, agentId, filePath); + on(clearFileContent, (state, { clientId, agentId, filePath, context }) => { + const key = getFileKey(clientId, agentId, filePath, context); const { [key]: removed, ...fileContents } = state.fileContents; return { ...state, @@ -306,8 +312,8 @@ export const filesReducer = createReducer( }; }), // Clear Directory Listing - on(clearDirectoryListing, (state, { clientId, agentId, directoryPath }) => { - const key = getFileKey(clientId, agentId, directoryPath); + on(clearDirectoryListing, (state, { clientId, agentId, directoryPath, context }) => { + const key = getFileKey(clientId, agentId, directoryPath, context); const { [key]: removed, ...directoryListings } = state.directoryListings; return { ...state, @@ -315,8 +321,8 @@ export const filesReducer = createReducer( }; }), // Open File Tab - on(openFileTab, (state, { clientId, agentId, filePath }) => { - const key = getClientAgentKey(clientId, agentId); + on(openFileTab, (state, { clientId, agentId, filePath, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[key] || []; // Keep only pinned tabs, remove all unpinned tabs const pinnedTabs = currentTabs.filter((tab) => tab.pinned); @@ -336,8 +342,8 @@ export const filesReducer = createReducer( }; }), // Close File Tab - on(closeFileTab, (state, { clientId, agentId, filePath }) => { - const key = getClientAgentKey(clientId, agentId); + on(closeFileTab, (state, { clientId, agentId, filePath, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[key] || []; const updatedTabs = currentTabs.filter((tab) => tab.filePath !== filePath); return { @@ -349,8 +355,8 @@ export const filesReducer = createReducer( }; }), // Pin File Tab - on(pinFileTab, (state, { clientId, agentId, filePath }) => { - const key = getClientAgentKey(clientId, agentId); + on(pinFileTab, (state, { clientId, agentId, filePath, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[key] || []; const updatedTabs = currentTabs.map((tab) => (tab.filePath === filePath ? { ...tab, pinned: true } : tab)); return { @@ -362,8 +368,8 @@ export const filesReducer = createReducer( }; }), // Unpin File Tab - on(unpinFileTab, (state, { clientId, agentId, filePath }) => { - const key = getClientAgentKey(clientId, agentId); + on(unpinFileTab, (state, { clientId, agentId, filePath, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[key] || []; const updatedTabs = currentTabs.map((tab) => (tab.filePath === filePath ? { ...tab, pinned: false } : tab)); return { @@ -375,8 +381,8 @@ export const filesReducer = createReducer( }; }), // Move Tab To Front - on(moveTabToFront, (state, { clientId, agentId, filePath }) => { - const key = getClientAgentKey(clientId, agentId); + on(moveTabToFront, (state, { clientId, agentId, filePath, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const currentTabs = state.openTabs[key] || []; const tabIndex = currentTabs.findIndex((tab) => tab.filePath === filePath); if (tabIndex === -1 || tabIndex === 0) { @@ -394,8 +400,8 @@ export const filesReducer = createReducer( }; }), // Clear Open Tabs - on(clearOpenTabs, (state, { clientId, agentId }) => { - const key = getClientAgentKey(clientId, agentId); + on(clearOpenTabs, (state, { clientId, agentId, context }) => { + const key = getClientAgentContextKey(clientId, agentId, context); const { [key]: removed, ...openTabs } = state.openTabs; return { ...state, diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.spec.ts index 371b8446..33e723a9 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.spec.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.spec.ts @@ -23,9 +23,9 @@ describe('Files Selectors', () => { const agentId = 'agent-1'; const filePath = 'test-file.txt'; const directoryPath = '.'; - const fileKey = `${clientId}:${agentId}:${filePath}`; - const directoryKey = `${clientId}:${agentId}:${directoryPath}`; - const clientAgentKey = `${clientId}:${agentId}`; + const fileKey = `${clientId}:${agentId}:app:${filePath}`; + const directoryKey = `${clientId}:${agentId}:app:${directoryPath}`; + const clientAgentKey = `${clientId}:${agentId}:app`; const mockFileContent: FileContentDto = { content: Buffer.from('Hello, World!', 'utf-8').toString('base64'), @@ -79,6 +79,18 @@ describe('Files Selectors', () => { const result = selector.projector(mockFilesState.fileContents); expect(result).toBeNull(); }); + + it('should resolve config context keys separately from app', () => { + const configKey = `${clientId}:${agentId}:config:${filePath}`; + const configContent: FileContentDto = { + content: Buffer.from('x', 'utf-8').toString('base64'), + encoding: 'utf-8', + }; + const fileContents = { ...mockFilesState.fileContents, [configKey]: configContent }; + const selector = selectFileContent(clientId, agentId, filePath, 'config'); + const result = selector.projector(fileContents); + expect(result).toEqual(configContent); + }); }); describe('selectIsReadingFile', () => { @@ -291,7 +303,7 @@ describe('Files Selectors', () => { it('should handle multiple client/agent combinations independently', () => { const clientId2 = 'client-2'; const agentId2 = 'agent-2'; - const clientAgentKey2 = `${clientId2}:${agentId2}`; + const clientAgentKey2 = `${clientId2}:${agentId2}:app`; const mockOpenTabs2: OpenTab[] = [{ filePath: 'file3.txt', pinned: false }]; const multiState = { diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.ts index 5b76ff50..5023de69 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.selectors.ts @@ -1,5 +1,5 @@ import { createFeatureSelector, createSelector } from '@ngrx/store'; -import type { FileContentDto, FileNodeDto } from './files.types'; +import type { FileContentDto, FileManagerContext, FileNodeDto } from './files.types'; import type { FilesState, OpenTab } from './files.reducer'; export const selectFilesState = createFeatureSelector('files'); @@ -15,85 +15,129 @@ export const selectFilesDeleting = createSelector(selectFilesState, (state) => s export const selectFilesMoving = createSelector(selectFilesState, (state) => state.moving); export const selectFilesErrors = createSelector(selectFilesState, (state) => state.errors); +function resolveFileContext(context?: FileManagerContext): FileManagerContext { + return context ?? 'app'; +} + /** - * Generate a key for file operations (clientId:agentId:path) + * Generate a key for file operations (clientId:agentId:context:path) */ -function getFileKey(clientId: string, agentId: string, path: string): string { - return `${clientId}:${agentId}:${path}`; +function getFileKey(clientId: string, agentId: string, path: string, context?: FileManagerContext): string { + const c = resolveFileContext(context); + return `${clientId}:${agentId}:${c}:${path}`; } // File content selectors (factory functions) -export const selectFileContent = (clientId: string, agentId: string, filePath: string) => +export const selectFileContent = (clientId: string, agentId: string, filePath: string, context?: FileManagerContext) => createSelector(selectFileContents, (fileContents) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return fileContents[key] ?? null; }); -export const selectIsReadingFile = (clientId: string, agentId: string, filePath: string) => +export const selectIsReadingFile = ( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, +) => createSelector(selectFilesReading, (reading) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return reading[key] ?? false; }); -export const selectIsWritingFile = (clientId: string, agentId: string, filePath: string) => +export const selectIsWritingFile = ( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, +) => createSelector(selectFilesWriting, (writing) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return writing[key] ?? false; }); // Directory listing selectors (factory functions) -export const selectDirectoryListing = (clientId: string, agentId: string, directoryPath: string) => +export const selectDirectoryListing = ( + clientId: string, + agentId: string, + directoryPath: string, + context?: FileManagerContext, +) => createSelector(selectDirectoryListings, (directoryListings) => { - const key = getFileKey(clientId, agentId, directoryPath); + const key = getFileKey(clientId, agentId, directoryPath, context); return directoryListings[key] ?? null; }); -export const selectIsListingDirectory = (clientId: string, agentId: string, directoryPath: string) => +export const selectIsListingDirectory = ( + clientId: string, + agentId: string, + directoryPath: string, + context?: FileManagerContext, +) => createSelector(selectFilesListing, (listing) => { - const key = getFileKey(clientId, agentId, directoryPath); + const key = getFileKey(clientId, agentId, directoryPath, context); return listing[key] ?? false; }); // Create/Delete selectors (factory functions) -export const selectIsCreatingFile = (clientId: string, agentId: string, filePath: string) => +export const selectIsCreatingFile = ( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, +) => createSelector(selectFilesCreating, (creating) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return creating[key] ?? false; }); -export const selectIsDeletingFile = (clientId: string, agentId: string, filePath: string) => +export const selectIsDeletingFile = ( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, +) => createSelector(selectFilesDeleting, (deleting) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return deleting[key] ?? false; }); -export const selectIsMovingFile = (clientId: string, agentId: string, filePath: string) => +export const selectIsMovingFile = (clientId: string, agentId: string, filePath: string, context?: FileManagerContext) => createSelector(selectFilesMoving, (moving) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return moving[key] ?? false; }); // Error selectors (factory functions) -export const selectFileError = (clientId: string, agentId: string, filePath: string) => +export const selectFileError = (clientId: string, agentId: string, filePath: string, context?: FileManagerContext) => createSelector(selectFilesErrors, (errors) => { - const key = getFileKey(clientId, agentId, filePath); + const key = getFileKey(clientId, agentId, filePath, context); return errors[key] ?? null; }); // Combined loading selector for a specific file operation -export const selectFileOperationLoading = (clientId: string, agentId: string, filePath: string) => +export const selectFileOperationLoading = ( + clientId: string, + agentId: string, + filePath: string, + context?: FileManagerContext, +) => createSelector( - selectIsReadingFile(clientId, agentId, filePath), - selectIsWritingFile(clientId, agentId, filePath), - selectIsCreatingFile(clientId, agentId, filePath), - selectIsDeletingFile(clientId, agentId, filePath), - selectIsMovingFile(clientId, agentId, filePath), + selectIsReadingFile(clientId, agentId, filePath, context), + selectIsWritingFile(clientId, agentId, filePath, context), + selectIsCreatingFile(clientId, agentId, filePath, context), + selectIsDeletingFile(clientId, agentId, filePath, context), + selectIsMovingFile(clientId, agentId, filePath, context), (reading, writing, creating, deleting, moving) => reading || writing || creating || deleting || moving, ); // Combined loading selector for a specific directory operation -export const selectDirectoryOperationLoading = (clientId: string, agentId: string, directoryPath: string) => - createSelector(selectIsListingDirectory(clientId, agentId, directoryPath), (listing) => listing); +export const selectDirectoryOperationLoading = ( + clientId: string, + agentId: string, + directoryPath: string, + context?: FileManagerContext, +) => createSelector(selectIsListingDirectory(clientId, agentId, directoryPath, context), (listing) => listing); // Open tabs selectors export const selectOpenTabs = createSelector(selectFilesState, (state) => state.openTabs); @@ -104,8 +148,9 @@ export const selectOpenTabs = createSelector(selectFilesState, (state) => state. * @param agentId - The agent ID * @returns Selector that returns array of open tabs */ -export const selectOpenTabsForClientAgent = (clientId: string, agentId: string) => +export const selectOpenTabsForClientAgent = (clientId: string, agentId: string, context?: FileManagerContext) => createSelector(selectOpenTabs, (openTabs) => { - const key = `${clientId}:${agentId}`; + const c = resolveFileContext(context); + const key = `${clientId}:${agentId}:${c}`; return openTabs[key] || []; }); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.types.ts index a1637be9..7d4d44c8 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.types.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/files/files.types.ts @@ -1,4 +1,8 @@ // Types based on OpenAPI spec - File System Operations + +/** Filesystem root for API calls: workspace (`app`) vs provider agent config (`config`). */ +export type FileManagerContext = 'app' | 'config'; + export interface FileContentDto { content: string; // base64-encoded encoding: 'utf-8' | 'base64'; @@ -27,5 +31,7 @@ export interface MoveFileDto { } export interface ListDirectoryParams { - path?: string; // Directory path relative to /app (defaults to '.') + path?: string; // Directory path relative to context root (defaults to '.') + /** When `config`, server requires workspace management rights. */ + context?: FileManagerContext; } 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 0b91476b..c174de9b 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 @@ -143,6 +143,7 @@ import { provideMonacoEditor } from 'ngx-monaco-editor-v2'; import { AuditComponent } from './audit/audit.component'; import { AgentConsoleChatComponent } from './chat/chat.component'; import { AgentConsoleContainerComponent } from './container/container.component'; +import { configEditorGuard } from './guards/config-editor.guard'; import { ticketsRequireActiveClientGuard } from './guards/tickets-require-active-client.guard'; import { TicketsBoardComponent } from './tickets/tickets-board.component'; @@ -201,6 +202,12 @@ export const agentConsoleRoutes: Route[] = [ component: AgentConsoleChatComponent, pathMatch: 'full', }, + { + path: ':clientId/agents/:agentId/config', + canActivate: [authGuard, configEditorGuard], + component: AgentConsoleChatComponent, + pathMatch: 'full', + }, { path: ':clientId/agents/:agentId/deployments', component: AgentConsoleChatComponent, 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 adad45cd..585c3099 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 @@ -18,11 +18,19 @@ } Agenstra - Studio + @if (fileManagerContext() === 'config') { + Agent config + } @else { + Studio + }
@@ -61,17 +69,19 @@ Terminal - + @if (fileManagerContext() === 'app') { + + }
@@ -650,6 +661,19 @@
Chat
Open Editor + @if ((activeClient$ | async)?.canManageWorkspaceConfiguration) { + + } } @if (activeClient$ | async; as activeClient) { @@ -1337,6 +1361,19 @@
Gateway
Open Editor + @if ((activeClient$ | async)?.canManageWorkspaceConfiguration) { + + } } @if (activeClient$ | async; as activeClient) { diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts index 95302cb3..6adc264f 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -15,7 +15,7 @@ import { import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/router'; import { AgentsFacade, AuthenticationFacade, @@ -45,6 +45,7 @@ import { type CreateFileDto, type DeploymentRun, type EnvironmentVariableResponseDto, + type FileManagerContext, type ForwardedEventPayload, type ProvisionServerDto, type TicketAutomationRunChatEventPayload, @@ -476,6 +477,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe selectedCommand = signal(null); selectedAgentId = signal(null); editorOpen = signal(false); + /** Active file editor API root from the current route (`/editor` vs `/config`). */ + fileManagerContext = signal('app'); deploymentManagerOpen = signal(false); chatVisible = signal(false); gatewayVisible = signal(false); @@ -494,6 +497,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // Convert signals to observables (must be in field initializer for injection context) private readonly standaloneMode$ = toObservable(this.standaloneMode); + private readonly fileManagerContext$ = toObservable(this.fileManagerContext); // Expose ContainerType enum for template use readonly ContainerType = ContainerType; @@ -908,6 +912,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } ngOnInit(): void { + this.fileManagerContext.set(this.router.url.includes('/config') ? 'config' : 'app'); + // Default chat model to auto mode on load this.socketsFacade.setChatModel(null); @@ -1029,11 +1035,11 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } } - // Select editor from route params + // Select editor from route params (workspace editor or provider config editor) if ( !this.initialRouting['editor'] && agents.length > 0 && - this.router.url.includes('/editor') && + (this.router.url.includes('/editor') || this.router.url.includes('/config')) && !this.editorOpen() ) { // Check if file query parameter is set @@ -1171,6 +1177,15 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } }); + this.router.events + .pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(() => { + this.fileManagerContext.set(this.router.url.includes('/config') ? 'config' : 'app'); + }); + // Reset editor view when selected agent changes and load commands this.selectedAgent$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((agent) => { const currentAgentId = agent?.id || null; @@ -1382,18 +1397,30 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // Silently fail - will use plain text fallback }); - // Watch for file content loading in standalone mode - combineLatest([this.standaloneMode$, this.selectedAgent$, this.activeClientId$, this.route.queryParams]) + // Watch for file content loading in standalone mode (must use same file API context as the editor route) + combineLatest([ + this.standaloneMode$, + this.selectedAgent$, + this.activeClientId$, + this.route.queryParams, + this.fileManagerContext$, + ]) .pipe( filter(([standalone, agent, clientId]) => { // Show loading if standalone mode is active and we have agent/client // If no file is specified, we'll hide loading immediately return standalone && !!agent && !!clientId && !this.standaloneFileLoaded; }), - switchMap(([, agent, clientId, queryParams]) => { + switchMap(([, agent, clientId, queryParams, fileContext]) => { // TypeScript guard: agent and clientId are checked in filter, but we need to assert here if (!agent || !clientId) { - return of({ error: false, filePath: undefined, clientId: undefined, agentId: undefined }); + return of({ + error: false, + filePath: undefined, + clientId: undefined, + agentId: undefined, + fileContext: undefined as FileManagerContext | undefined, + }); } // At this point, TypeScript knows agent and clientId are non-null const nonNullAgent = agent; @@ -1402,7 +1429,13 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe const filePathParam = queryParams?.['file']; // If no file is specified, hide loading immediately if (!filePathParam || typeof filePathParam !== 'string') { - return of({ error: false, filePath: undefined, clientId: nonNullClientId, agentId: nonNullAgent.id }); // No file to wait for + return of({ + error: false, + filePath: undefined, + clientId: nonNullClientId, + agentId: nonNullAgent.id, + fileContext, + }); // No file to wait for } const filePath: string = filePathParam; // Decode the file path @@ -1415,9 +1448,9 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe })(); // Watch for file content to be loaded or error to occur return combineLatest([ - this.filesFacade.isReadingFile$(nonNullClientId, nonNullAgent.id, decodedFilePath), - this.filesFacade.getFileContent$(nonNullClientId, nonNullAgent.id, decodedFilePath), - this.filesFacade.getFileError$(nonNullClientId, nonNullAgent.id, decodedFilePath), + this.filesFacade.isReadingFile$(nonNullClientId, nonNullAgent.id, decodedFilePath, fileContext), + this.filesFacade.getFileContent$(nonNullClientId, nonNullAgent.id, decodedFilePath, fileContext), + this.filesFacade.getFileError$(nonNullClientId, nonNullAgent.id, decodedFilePath, fileContext), ]).pipe( // Wait until file is not loading AND (content is available OR error occurred) filter(([isLoading, content, error]) => !isLoading && (content !== null || error !== null)), @@ -1427,6 +1460,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe filePath: decodedFilePath, clientId: nonNullClientId, agentId: nonNullAgent.id, + fileContext, })), catchError(() => { // Handle any unexpected errors @@ -1435,6 +1469,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe filePath: decodedFilePath, clientId: nonNullClientId, agentId: nonNullAgent.id, + fileContext, }); }), ); @@ -1447,7 +1482,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // If file was not found (error occurred), unselect the file and close the tab if (result?.error && result.filePath && result.clientId && result.agentId) { // Close the tab - this.filesFacade.closeFileTab(result.clientId, result.agentId, result.filePath); + this.filesFacade.closeFileTab(result.clientId, result.agentId, result.filePath, result.fileContext); // Open chat if it's not open if (!this.chatVisible()) { this.chatVisible.set(true); @@ -1567,6 +1602,9 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe * Wrapper for fileEditor's onToggleGitManager that syncs visibility after toggle */ onToggleGitManager(): void { + if (this.fileManagerContext() === 'config') { + return; + } if (this.fileEditor) { this.fileEditor.onToggleGitManager(); this.syncFileEditorVisibility(); @@ -1860,6 +1898,22 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe setTimeout(() => this.syncFileEditorVisibility(), 0); } + /** + * Navigate to the provider agent config file editor (requires workspace management access). + */ + onOpenAgentConfigFiles(openInNewWindow = false): void { + const clientId = this.activeClientId; + const agentId = this.selectedAgentId(); + if (!clientId || !agentId) { + return; + } + if (openInNewWindow) { + this.openAgentConfigInNewWindow(); + return; + } + void this.router.navigate(['/clients', clientId, 'agents', agentId, 'config']); + } + /** * Open virtual desktop for the selected agent */ @@ -1970,6 +2024,12 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe : $localize`:@@featureChat-openEditor:Open Editor`; } + getOpenAgentConfigFilesTitle(): string { + return this.getOpenInNewWindow() + ? $localize`:@@featureChat-openAgentConfigFilesNewWindow:Open agent config in New Window` + : $localize`:@@featureChat-openAgentConfigFilesTitle:Open provider agent config files (requires workspace management access)`; + } + getDeploymentManagerToggleTitle(): string { const openInNew = this.getDeploymentOpenInNewWindow(); const isOpen = this.deploymentManagerOpen(); @@ -1999,7 +2059,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // Build the URL const baseUrl = window.location.origin; - const editorPath = `/clients/${clientId}/agents/${agentId}/editor`; + const segment = this.fileManagerContext() === 'config' ? 'config' : 'editor'; + const editorPath = `/clients/${clientId}/agents/${agentId}/${segment}`; const queryParams = new URLSearchParams(); queryParams.set('standalone', 'true'); if (filePath) { @@ -2051,6 +2112,67 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } } + /** + * Open the config file editor in a new window with standalone mode (same as {@link openEditorInNewWindow}). + */ + private openAgentConfigInNewWindow(): void { + const clientId = this.activeClientId; + const agentId = this.selectedAgentId(); + if (!clientId || !agentId) { + return; + } + + let filePath: string | undefined; + if (this.fileEditor && this.fileManagerContext() === 'config') { + filePath = this.fileEditor.selectedFilePath() || undefined; + } + + const baseUrl = window.location.origin; + const configPath = `/clients/${clientId}/agents/${agentId}/config`; + const queryParams = new URLSearchParams(); + queryParams.set('standalone', 'true'); + if (filePath) { + queryParams.set('file', encodeURIComponent(filePath)); + } + const url = `${baseUrl}${configPath}?${queryParams.toString()}`; + + const screenWidth = window.screen.availWidth || window.screen.width; + const screenHeight = window.screen.availHeight || window.screen.height; + + const windowFeatures = [ + 'menubar=no', + 'toolbar=no', + 'location=no', + 'status=no', + 'resizable=yes', + 'scrollbars=yes', + `width=${screenWidth}`, + `height=${screenHeight}`, + `left=0`, + `top=0`, + ].join(','); + + const newWindow = window.open(url, '_blank', windowFeatures); + + if (newWindow) { + setTimeout(() => { + try { + newWindow.moveTo(0, 0); + newWindow.resizeTo(screenWidth, screenHeight); + if (newWindow.screen && 'availWidth' in newWindow.screen) { + const availWidth = (newWindow.screen as Screen & { availWidth?: number }).availWidth; + const availHeight = (newWindow.screen as Screen & { availHeight?: number }).availHeight; + if (availWidth && availHeight) { + newWindow.resizeTo(availWidth, availHeight); + } + } + } catch (e) { + console.warn('Could not maximize window:', e); + } + }, 100); + } + } + onToggleChat(): void { this.chatVisible.update((visible) => !visible); // Recalculate file editor tabs when chat visibility changes @@ -2260,7 +2382,8 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // Build the URL const baseUrl = window.location.origin; - const editorPath = `/clients/${clientId}/agents/${agentId}/editor`; + const segment = this.fileManagerContext() === 'config' ? 'config' : 'editor'; + const editorPath = `/clients/${clientId}/agents/${agentId}/${segment}`; const queryParams = new URLSearchParams(); queryParams.set('standalone', 'true'); queryParams.set('file', encodeURIComponent(filePath)); diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts index 96bab8f7..dbec95d4 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/container/container.component.ts @@ -43,6 +43,7 @@ export class AgentConsoleContainerComponent implements OnInit { url.includes('/audit') || url.includes('/tickets')) && !url.includes('/editor') && + !url.includes('/config') && !url.includes('/deployments'), ), ), @@ -53,6 +54,7 @@ export class AgentConsoleContainerComponent implements OnInit { this.router.url.includes('/audit') || this.router.url.includes('/tickets')) && !this.router.url.includes('/editor') && + !this.router.url.includes('/config') && !this.router.url.includes('/deployments'), }, ); diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-editor.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-editor.component.html index f6fd51e1..024cca8d 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-editor.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-editor.component.html @@ -35,6 +35,7 @@
File Tree File Tree > - - @if (gitManagerVisible()) { + + @if (fileManagerContext() === 'app' && gitManagerVisible()) {
Ver }
- @if (gitDiffViewerVisible()) { + @if (fileManagerContext() === 'app' && gitDiffViewerVisible()) {
(); agentId = input.required(); chatVisible = input(true); + /** Files API root: workspace (`app`) or provider agent config (`config`). */ + fileManagerContext = input('app'); // Internal state selectedFilePath = signal(null); @@ -119,23 +123,26 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Refresh debounce subject to prevent multiple rapid refreshes private readonly refreshTrigger$ = new Subject(); private isRefreshing = false; + private previousFileManagerContext: FileManagerContext | null = null; // Convert signals to observables private readonly selectedFilePath$ = toObservable(this.selectedFilePath); private readonly clientId$ = toObservable(this.clientId); private readonly agentId$ = toObservable(this.agentId); + private readonly fileManagerContext$ = toObservable(this.fileManagerContext); // Computed observables readonly selectedFileContent$: Observable = combineLatest([ this.selectedFilePath$, this.clientId$, this.agentId$, + this.fileManagerContext$, ]).pipe( - switchMap(([filePath, clientId, agentId]) => { + switchMap(([filePath, clientId, agentId, fileCtx]) => { if (!filePath || !clientId || !agentId) { return of(null); } - return this.filesFacade.getFileContent$(clientId, agentId, filePath); + return this.filesFacade.getFileContent$(clientId, agentId, filePath, fileCtx); }), ); @@ -143,15 +150,16 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { this.selectedFilePath$, this.clientId$, this.agentId$, + this.fileManagerContext$, toObservable(this.lastLoadedFilePath), ]).pipe( - switchMap(([filePath, clientId, agentId, lastLoadedFilePath]) => { + switchMap(([filePath, clientId, agentId, fileCtx, lastLoadedFilePath]) => { if (!filePath || !clientId || !agentId) { return of(false); } return this.filesFacade - .isReadingFile$(clientId, agentId, filePath) + .isReadingFile$(clientId, agentId, filePath, fileCtx) .pipe(map((isLoading) => isLoading && lastLoadedFilePath !== filePath)); }), ); @@ -160,12 +168,13 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { this.selectedFilePath$, this.clientId$, this.agentId$, + this.fileManagerContext$, ]).pipe( - switchMap(([filePath, clientId, agentId]) => { + switchMap(([filePath, clientId, agentId, fileCtx]) => { if (!filePath || !clientId || !agentId) { return of(false); } - return this.filesFacade.isWritingFile$(clientId, agentId, filePath); + return this.filesFacade.isWritingFile$(clientId, agentId, filePath, fileCtx); }), ); @@ -180,23 +189,46 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { }); // Open tabs - readonly openTabs$: Observable = combineLatest([this.clientId$, this.agentId$]).pipe( - switchMap(([clientId, agentId]) => { + readonly openTabs$: Observable = combineLatest([ + this.clientId$, + this.agentId$, + this.fileManagerContext$, + ]).pipe( + switchMap(([clientId, agentId, fileCtx]) => { if (!clientId || !agentId) { return of([]); } - return this.filesFacade.getOpenTabs$(clientId, agentId); + return this.filesFacade.getOpenTabs$(clientId, agentId, fileCtx); }), ); private resizeObserver?: ResizeObserver; + private listParams(path: string): ListDirectoryParams { + const c = this.fileManagerContext(); + return c === 'app' ? { path } : { path, context: c }; + } + constructor() { + effect(() => { + const ctx = this.fileManagerContext(); + if (this.previousFileManagerContext !== null && this.previousFileManagerContext !== ctx) { + this.selectedFilePath.set(null); + this.lastLoadedFilePath.set(null); + this.dirtyFiles.set(new Set()); + this.expandedPaths.set(new Set()); + this.gitManagerVisible.set(false); + this.gitDiffViewerVisible.set(false); + this.gitDiffFilePath.set(null); + } + this.previousFileManagerContext = ctx; + }); + // Load file when selected effect(() => { const filePath = this.selectedFilePath(); if (filePath && this.clientId() && this.agentId()) { - this.filesFacade.readFile(this.clientId(), this.agentId(), filePath); + this.filesFacade.readFile(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); } }); @@ -231,7 +263,12 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { const overflowed = this.overflowedTabs(); const isOverflowed = overflowed.some((tab) => tab.filePath === selectedPath); if (isOverflowed) { - this.filesFacade.moveTabToFront(this.clientId(), this.agentId(), selectedPath); + this.filesFacade.moveTabToFront( + this.clientId(), + this.agentId(), + selectedPath, + this.fileManagerContext(), + ); // Recalculate after moving setTimeout(() => this.calculateVisibleTabs(), 50); } @@ -262,9 +299,13 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { const currentSelectedPath = this.selectedFilePath(); const clientId = this.clientId(); const agentId = this.agentId(); + const actionCtx = action.context ?? 'app'; + if (actionCtx !== this.fileManagerContext()) { + return; + } - // Reload git status after file move - if (clientId === action.clientId && agentId === action.agentId) { + // Reload git status after file move (workspace only) + if (this.fileManagerContext() === 'app' && clientId === action.clientId && agentId === action.agentId) { setTimeout(() => { this.vcsFacade.loadStatus(clientId, agentId); }, 500); @@ -288,7 +329,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Load the file content at the new location // The effect will automatically load it when selectedFilePath changes - this.filesFacade.readFile(clientId, agentId, action.destinationPath); + this.filesFacade.readFile(clientId, agentId, action.destinationPath, this.fileManagerContext()); } }); @@ -420,7 +461,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Open tab when file is selected // The effect will automatically move it to front if it ends up in overflow if (this.clientId() && this.agentId()) { - this.filesFacade.openFileTab(this.clientId(), this.agentId(), filePath); + this.filesFacade.openFileTab(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); } } @@ -496,7 +537,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { encoding: 'utf-8', }; - this.filesFacade.writeFile(clientId, agentId, filePath, writeDto); + this.filesFacade.writeFile(clientId, agentId, filePath, writeDto, this.fileManagerContext()); // Mark as not dirty and sync editorContent after successful save // Also emit file update notification to other clients after successful save @@ -531,10 +572,12 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { return newSaved; }); - // Reload git status after file save - setTimeout(() => { - this.vcsFacade.loadStatus(clientId, agentId); - }, 300); + // Reload git status after file save (workspace only) + if (this.fileManagerContext() === 'app') { + setTimeout(() => { + this.vcsFacade.loadStatus(clientId, agentId); + }, 300); + } // Emit file update notification to other clients after successful save // agentId is required for routing the event to the correct agent @@ -595,15 +638,23 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { }; const fullPath = event.path === '.' ? event.name : `${event.path}/${event.name}`; - this.filesFacade.createFileOrDirectory(this.clientId(), this.agentId(), fullPath, createDto); + this.filesFacade.createFileOrDirectory( + this.clientId(), + this.agentId(), + fullPath, + createDto, + this.fileManagerContext(), + ); // Refresh directory listing - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: event.path }); + this.filesFacade.listDirectory(this.clientId(), this.agentId(), this.listParams(event.path)); - // Reload git status after file creation - setTimeout(() => { - this.vcsFacade.loadStatus(this.clientId(), this.agentId()); - }, 500); + // Reload git status after file creation (workspace only) + if (this.fileManagerContext() === 'app') { + setTimeout(() => { + this.vcsFacade.loadStatus(this.clientId(), this.agentId()); + }, 500); + } // If it's a file, select it if (event.type === 'file') { @@ -622,7 +673,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { onFileDelete(filePath: string): void { // Confirmation is handled by the file-tree component's Bootstrap modal - this.filesFacade.deleteFileOrDirectory(this.clientId(), this.agentId(), filePath); + this.filesFacade.deleteFileOrDirectory(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); // If deleted file was selected, clear selection if (this.selectedFilePath() === filePath) { @@ -635,12 +686,14 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { } // Refresh root directory listing - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: '.' }); + this.filesFacade.listDirectory(this.clientId(), this.agentId(), this.listParams('.')); - // Reload git status after file deletion - setTimeout(() => { - this.vcsFacade.loadStatus(this.clientId(), this.agentId()); - }, 500); + // Reload git status after file deletion (workspace only) + if (this.fileManagerContext() === 'app') { + setTimeout(() => { + this.vcsFacade.loadStatus(this.clientId(), this.agentId()); + }, 500); + } } onDirectoryExpand(path: string | Event): void { @@ -714,17 +767,17 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { if (path === '.') { // Small delay for root directory to avoid cancellation setTimeout(() => { - this.filesFacade.listDirectory(clientId, agentId, { path: '.' }); + this.filesFacade.listDirectory(clientId, agentId, this.listParams('.')); }, 50); } else { - this.filesFacade.listDirectory(clientId, agentId, { path }); + this.filesFacade.listDirectory(clientId, agentId, this.listParams(path)); } }); // If root is not in expanded paths, reload it anyway (it's always needed) if (!currentExpandedPaths.has('.')) { setTimeout(() => { - this.filesFacade.listDirectory(clientId, agentId, { path: '.' }); + this.filesFacade.listDirectory(clientId, agentId, this.listParams('.')); }, 50); } @@ -736,7 +789,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Reload currently selected file if it exists if (currentSelectedFile) { - this.filesFacade.readFile(clientId, agentId, currentSelectedFile); + this.filesFacade.readFile(clientId, agentId, currentSelectedFile, this.fileManagerContext()); } } @@ -754,7 +807,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Move the tab to the front before selecting it if (this.clientId() && this.agentId()) { - this.filesFacade.moveTabToFront(this.clientId(), this.agentId(), filePath); + this.filesFacade.moveTabToFront(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); } this.onTabClick(filePath); @@ -781,7 +834,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { event.preventDefault(); event.stopPropagation(); if (this.clientId() && this.agentId()) { - this.filesFacade.pinFileTab(this.clientId(), this.agentId(), filePath); + this.filesFacade.pinFileTab(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); } } @@ -789,7 +842,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { event.preventDefault(); event.stopPropagation(); if (this.clientId() && this.agentId()) { - this.filesFacade.closeFileTab(this.clientId(), this.agentId(), filePath); + this.filesFacade.closeFileTab(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); // If the closed tab was selected, select the first remaining tab or clear selection if (this.selectedFilePath() === filePath) { this.openTabs$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe((tabs) => { @@ -821,7 +874,8 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Build the URL const baseUrl = window.location.origin; - const editorPath = `/clients/${clientId}/agents/${agentId}/editor`; + const segment = this.fileManagerContext() === 'config' ? 'config' : 'editor'; + const editorPath = `/clients/${clientId}/agents/${agentId}/${segment}`; const queryParams = new URLSearchParams(); queryParams.set('standalone', 'true'); queryParams.set('file', encodeURIComponent(filePath)); @@ -874,7 +928,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { // Wait a bit to ensure the new window has opened before closing the tab setTimeout(() => { if (this.clientId() && this.agentId()) { - this.filesFacade.closeFileTab(this.clientId(), this.agentId(), filePath); + this.filesFacade.closeFileTab(this.clientId(), this.agentId(), filePath, this.fileManagerContext()); // If the closed tab was selected, select the first remaining tab or clear selection if (this.selectedFilePath() === filePath) { this.openTabs$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe((tabs) => { @@ -969,7 +1023,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { const isOverflowed = overflowed.some((tab) => tab.filePath === selectedPath); if (isOverflowed) { // Move selected tab to front and recalculate - this.filesFacade.moveTabToFront(this.clientId(), this.agentId(), selectedPath); + this.filesFacade.moveTabToFront(this.clientId(), this.agentId(), selectedPath, this.fileManagerContext()); // The tab order change will trigger a recalculation via the effect // But we also need to recalculate immediately to show the change setTimeout(() => this.calculateVisibleTabs(), 50); @@ -1003,6 +1057,9 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { } onToggleGitManager(): void { + if (this.fileManagerContext() !== 'app') { + return; + } const wasVisible = this.gitManagerVisible(); this.gitManagerVisible.update((visible) => !visible); @@ -1021,6 +1078,9 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { } onShowGitDiff(filePath: string): void { + if (this.fileManagerContext() !== 'app') { + return; + } this.gitDiffFilePath.set(filePath); this.gitDiffViewerVisible.set(true); } @@ -1040,6 +1100,10 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { * - If file is not dirty: automatically reloads the file from server */ private handleFileUpdateNotification(notification: FileUpdateNotificationData): void { + if (this.fileManagerContext() !== 'app') { + return; + } + const currentFilePath = this.selectedFilePath(); const currentSocketId = getSocketInstance()?.id; const clientId = this.clientId(); @@ -1080,7 +1144,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { } } else { // File is not dirty - automatically reload from server (no need to disable autosave) - this.filesFacade.readFile(clientId, agentId, notification.filePath); + this.filesFacade.readFile(clientId, agentId, notification.filePath, this.fileManagerContext()); } } } @@ -1138,11 +1202,11 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { } // Clear cached content first so Monaco wrapper receives a fresh emission and updates correctly - this.filesFacade.clearFileContent(clientId, agentId, filePath); + this.filesFacade.clearFileContent(clientId, agentId, filePath, this.fileManagerContext()); // Reset lastLoadedFilePath so the content effect will run when new content arrives this.lastLoadedFilePath.set(null); // Reload the file from server - this.filesFacade.readFile(clientId, agentId, filePath); + this.filesFacade.readFile(clientId, agentId, filePath, this.fileManagerContext()); // Clear dirty state for this file this.dirtyFiles.update((dirty) => { @@ -1165,10 +1229,12 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { this.showFileUpdateModal.set(false); this.fileUpdateNotification.set(null); - // Reload git status after accepting file update - setTimeout(() => { - this.vcsFacade.loadStatus(clientId, agentId); - }, 500); + // Reload git status after accepting file update (workspace only) + if (this.fileManagerContext() === 'app') { + setTimeout(() => { + this.vcsFacade.loadStatus(clientId, agentId); + }, 500); + } } /** @@ -1291,7 +1357,7 @@ export class FileEditorComponent implements OnDestroy, AfterViewInit { ngOnDestroy(): void { // Clear all open tabs when component is destroyed if (this.clientId() && this.agentId()) { - this.filesFacade.clearOpenTabs(this.clientId(), this.agentId()); + this.filesFacade.clearOpenTabs(this.clientId(), this.agentId(), this.fileManagerContext()); } // Clean up ResizeObserver if (this.resizeObserver) { diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.html b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.html index 7607ddc0..077f55bc 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.html +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.html @@ -132,57 +132,59 @@
Files
}
- @if (clientRepositoryName$ | async; as repoName) { -
-
- - - {{ repoName }} - - @if (currentBranch$ | async; as branch) { -
- - - {{ branch }} - - @if (statusIndicator$ | async; as indicator) { - +
+ + + {{ repoName }} + + @if (currentBranch$ | async; as branch) { +
+ - @if (showStatusIndicatorSpinner$ | async) { - - Loading... - - } - - } -
- } + + {{ branch }} + + @if (statusIndicator$ | async; as indicator) { + + @if (showStatusIndicatorSpinner$ | async) { + + Loading... + + } + + } +
+ } +
-
- } + } - - + + + } diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.ts index 26e26065..5a4810d1 100644 --- a/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.ts +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/file-editor/file-tree/file-tree.component.ts @@ -18,7 +18,9 @@ import { ClientsFacade, FilesFacade, VcsFacade, + type FileManagerContext, type FileNodeDto, + type ListDirectoryParams, } from '@forepath/framework/frontend/data-access-agent-console'; import { combineLatest, filter, map, Observable, of, Subscription, switchMap, take } from 'rxjs'; import { GitBranchModalComponent } from '../git-branch-modal/git-branch-modal.component'; @@ -68,6 +70,8 @@ export class FileTreeComponent implements OnInit { expandedPaths = input>(new Set()); selectedPath = input(null); gitManagerVisible = input(false); + /** Files API root: workspace (`app`) or provider agent config (`config`). */ + fileManagerContext = input('app'); // Outputs fileSelect = output(); @@ -96,15 +100,24 @@ export class FileTreeComponent implements OnInit { private hoverTimeout: ReturnType | null = null; private expandedDirectorySubscriptions = new Map(); + private listParams(path: string): ListDirectoryParams { + const c = this.fileManagerContext(); + return c === 'app' ? { path } : { path, context: c }; + } + + private listDirectoryRel(path: string): void { + this.filesFacade.listDirectory(this.clientId(), this.agentId(), this.listParams(path)); + } + // Computed observables for directory listings - convert computed signals to observables private readonly rootDirectorySignal = computed(() => { const clientId = this.clientId(); const agentId = this.agentId(); + const context = this.fileManagerContext(); if (!clientId || !agentId) { return null; } - // Return a placeholder - we'll use toObservable to convert the signal - return { clientId, agentId }; + return { clientId, agentId, context }; }); readonly rootDirectory$: Observable = toObservable(this.rootDirectorySignal).pipe( @@ -112,17 +125,18 @@ export class FileTreeComponent implements OnInit { if (!config) { return of(null); } - return this.filesFacade.getDirectoryListing$(config.clientId, config.agentId, '.'); + return this.filesFacade.getDirectoryListing$(config.clientId, config.agentId, '.', config.context); }), ); private readonly rootLoadingSignal = computed(() => { const clientId = this.clientId(); const agentId = this.agentId(); + const context = this.fileManagerContext(); if (!clientId || !agentId) { return false; } - return { clientId, agentId }; + return { clientId, agentId, context }; }); readonly rootLoading$: Observable = toObservable(this.rootLoadingSignal).pipe( @@ -132,8 +146,8 @@ export class FileTreeComponent implements OnInit { } // Only show loading if we don't have cached data (silent refresh) return combineLatest([ - this.filesFacade.isListingDirectory$(config.clientId, config.agentId, '.'), - this.filesFacade.getDirectoryListing$(config.clientId, config.agentId, '.'), + this.filesFacade.isListingDirectory$(config.clientId, config.agentId, '.', config.context), + this.filesFacade.getDirectoryListing$(config.clientId, config.agentId, '.', config.context), ]).pipe( map(([isLoading, cachedData]) => { // Show loading only if loading AND no cached data exists @@ -204,12 +218,12 @@ export class FileTreeComponent implements OnInit { // Helper to get directory listing observable getDirectoryListing$(path: string): Observable { - return this.filesFacade.getDirectoryListing$(this.clientId(), this.agentId(), path); + return this.filesFacade.getDirectoryListing$(this.clientId(), this.agentId(), path, this.fileManagerContext()); } // Helper to get directory loading observable getDirectoryLoading$(path: string): Observable { - return this.filesFacade.isListingDirectory$(this.clientId(), this.agentId(), path); + return this.filesFacade.isListingDirectory$(this.clientId(), this.agentId(), path, this.fileManagerContext()); } constructor() { @@ -227,9 +241,10 @@ export class FileTreeComponent implements OnInit { this.hasLoadedContent.set(false); this.hasHadOperation.set(false); this.isReloadingAfterOperation.set(false); - this.filesFacade.listDirectory(clientId, agentId, { path: '.' }); - // Load git status - this.vcsFacade.loadStatus(clientId, agentId); + this.filesFacade.listDirectory(clientId, agentId, this.listParams('.')); + if (this.fileManagerContext() === 'app') { + this.vcsFacade.loadStatus(clientId, agentId); + } } }); @@ -384,7 +399,7 @@ export class FileTreeComponent implements OnInit { if (!hasCachedData) { // Only show loading if we don't have cached data (silent refresh) node.loading = true; - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: node.path }); + this.listDirectoryRel(node.path); // Subscribe to directory listing this.getDirectoryListing$(node.path) .pipe( @@ -401,7 +416,7 @@ export class FileTreeComponent implements OnInit { }); } else { // We have cached data, but still reload to get fresh data (silent) - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: node.path }); + this.listDirectoryRel(node.path); } this.directoryExpand.emit(node.path); } @@ -691,21 +706,27 @@ export class FileTreeComponent implements OnInit { } // Use move functionality - this.filesFacade.moveFileOrDirectory(this.clientId(), this.agentId(), dragged.path, { - destination: destinationPath, - }); + this.filesFacade.moveFileOrDirectory( + this.clientId(), + this.agentId(), + dragged.path, + { + destination: destinationPath, + }, + this.fileManagerContext(), + ); this.draggedItem.set(null); // Refresh source parent directory const sourceParentPath = this.getParentPath(dragged.path); setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: sourceParentPath }); + this.listDirectoryRel(sourceParentPath); }, 100); // Refresh destination directory setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: node.path }); + this.listDirectoryRel(node.path); }, 200); // Expand target path in the tree @@ -802,20 +823,26 @@ export class FileTreeComponent implements OnInit { } // Use move functionality - this.filesFacade.moveFileOrDirectory(this.clientId(), this.agentId(), dragged.path, { - destination: destinationPath, - }); + this.filesFacade.moveFileOrDirectory( + this.clientId(), + this.agentId(), + dragged.path, + { + destination: destinationPath, + }, + this.fileManagerContext(), + ); this.draggedItem.set(null); // Refresh source parent directory setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: sourceParentPath }); + this.listDirectoryRel(sourceParentPath); }, 100); // Refresh root directory setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: '.' }); + this.listDirectoryRel('.'); }, 200); } @@ -847,7 +874,7 @@ export class FileTreeComponent implements OnInit { // Load directory if not cached const hasCachedData = this.treeCache().has(path); if (!hasCachedData) { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path }); + this.listDirectoryRel(path); } } this.hoverTimeout = null; @@ -891,7 +918,7 @@ export class FileTreeComponent implements OnInit { // Wait a bit for the file/directory to be created, then refresh the parent directory listing setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: creating.path }); + this.listDirectoryRel(creating.path); }, 100); this.creatingItem.set(null); @@ -951,7 +978,7 @@ export class FileTreeComponent implements OnInit { this.removeFromCache(item.path); // Refresh the parent directory listing to update the tree - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: parentPath }); + this.listDirectoryRel(parentPath); } } @@ -969,9 +996,15 @@ export class FileTreeComponent implements OnInit { const destinationPath = parentPath === '.' ? newName : `${parentPath}/${newName}`; // Use move functionality to rename - this.filesFacade.moveFileOrDirectory(this.clientId(), this.agentId(), item.path, { - destination: destinationPath, - }); + this.filesFacade.moveFileOrDirectory( + this.clientId(), + this.agentId(), + item.path, + { + destination: destinationPath, + }, + this.fileManagerContext(), + ); this.hideModal(this.renameFileModal); this.itemToRename.set(null); @@ -979,7 +1012,7 @@ export class FileTreeComponent implements OnInit { // Refresh the parent directory listing to update the tree setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: parentPath }); + this.listDirectoryRel(parentPath); }, 100); } @@ -1002,9 +1035,15 @@ export class FileTreeComponent implements OnInit { } // Use move functionality - this.filesFacade.moveFileOrDirectory(this.clientId(), this.agentId(), item.path, { - destination: fullDestinationPath, - }); + this.filesFacade.moveFileOrDirectory( + this.clientId(), + this.agentId(), + item.path, + { + destination: fullDestinationPath, + }, + this.fileManagerContext(), + ); this.hideModal(this.moveFileModal); this.itemToMove.set(null); @@ -1013,13 +1052,13 @@ export class FileTreeComponent implements OnInit { // Refresh source parent directory const sourceParentPath = this.getParentPath(item.path); setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: sourceParentPath }); + this.listDirectoryRel(sourceParentPath); }, 100); // Refresh destination directory and expand target path in the tree const destinationParentPath = this.getParentPath(fullDestinationPath); setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: destinationParentPath }); + this.listDirectoryRel(destinationParentPath); }, 200); // Expand target path in the tree @@ -1063,7 +1102,7 @@ export class FileTreeComponent implements OnInit { // Load directory if not cached const hasCachedData = this.treeCache().has(path); if (!hasCachedData) { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path }); + this.listDirectoryRel(path); } } }, index * 100); // 100ms delay between each expansion @@ -1384,7 +1423,7 @@ export class FileTreeComponent implements OnInit { const pathsArray = Array.from(pathsToRefresh); pathsArray.forEach((path, index) => { setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path }); + this.listDirectoryRel(path); }, index * 50); // 50ms delay between each call }); } @@ -1432,7 +1471,7 @@ export class FileTreeComponent implements OnInit { pathsArray.forEach((path, index) => { setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path }); + this.listDirectoryRel(path); }, index * 50); // 50ms delay between each call }); } @@ -1471,10 +1510,16 @@ export class FileTreeComponent implements OnInit { const fullPath = targetPath === '.' ? file.name : `${targetPath}/${file.name}`; // Create file with content using createFileOrDirectory - this.filesFacade.createFileOrDirectory(this.clientId(), this.agentId(), fullPath, { - type: 'file', - content: base64Content, - }); + this.filesFacade.createFileOrDirectory( + this.clientId(), + this.agentId(), + fullPath, + { + type: 'file', + content: base64Content, + }, + this.fileManagerContext(), + ); uploadedCount++; @@ -1486,19 +1531,21 @@ export class FileTreeComponent implements OnInit { // Load directory if not cached const hasCachedData = this.treeCache().has(targetPath); if (!hasCachedData) { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: targetPath }); + this.listDirectoryRel(targetPath); } } // Refresh directory listing setTimeout(() => { - this.filesFacade.listDirectory(this.clientId(), this.agentId(), { path: targetPath }); + this.listDirectoryRel(targetPath); }, 100); - // Reload git status after file upload - setTimeout(() => { - this.vcsFacade.loadStatus(this.clientId(), this.agentId()); - }, 500); + // Reload git status after file upload (workspace app tree only) + if (this.fileManagerContext() === 'app') { + setTimeout(() => { + this.vcsFacade.loadStatus(this.clientId(), this.agentId()); + }, 500); + } // Select the last uploaded file if (fileArray.length === 1) { diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.spec.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.spec.ts new file mode 100644 index 00000000..3724a8b1 --- /dev/null +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.spec.ts @@ -0,0 +1,103 @@ +import { Injector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, type ActivatedRouteSnapshot, convertToParamMap, Router, type UrlTree } from '@angular/router'; +// eslint-disable-next-line @nx/enforce-module-boundaries +import { ClientsFacade } from '../../../../data-access-agent-console/src/lib/state/clients/clients.facade'; +import { firstValueFrom, isObservable, of, type Observable } from 'rxjs'; +import { configEditorGuard } from './config-editor.guard'; + +describe('configEditorGuard', () => { + const mockParentRoute = { path: 'parent' }; + + let mockRouter: { createUrlTree: jest.Mock }; + let mockActivatedRoute: { parent: typeof mockParentRoute }; + let clientsFacadeStub: { + getClientById$: jest.Mock; + loadClient: jest.Mock; + setActiveClient: jest.Mock; + }; + + const createInjector = (): Injector => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: ClientsFacade, useValue: clientsFacadeStub }, + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + ], + }); + return TestBed.inject(Injector); + }; + + async function runGuard(route: ActivatedRouteSnapshot): Promise { + const injector = createInjector(); + const raw = runInInjectionContext(injector, () => configEditorGuard(route, {} as never)); + if (isObservable(raw)) { + return firstValueFrom(raw as Observable); + } + return raw as boolean | UrlTree; + } + + beforeEach(() => { + mockRouter = { + createUrlTree: jest.fn(), + }; + mockActivatedRoute = { parent: mockParentRoute }; + clientsFacadeStub = { + getClientById$: jest.fn(), + loadClient: jest.fn(), + setActiveClient: jest.fn(), + }; + jest.clearAllMocks(); + }); + + it('allows activation when the user can manage workspace configuration', async () => { + clientsFacadeStub.getClientById$.mockReturnValue( + of({ + id: 'c1', + canManageWorkspaceConfiguration: true, + } as never), + ); + const route = { + paramMap: convertToParamMap({ clientId: 'c1', agentId: 'a1' }), + } as ActivatedRouteSnapshot; + + const result = await runGuard(route); + + expect(clientsFacadeStub.setActiveClient).toHaveBeenCalledWith('c1'); + expect(clientsFacadeStub.loadClient).toHaveBeenCalledWith('c1'); + expect(result).toBe(true); + expect(mockRouter.createUrlTree).not.toHaveBeenCalled(); + }); + + it('redirects to agent chat when the user cannot manage workspace configuration', async () => { + const urlTree = { toString: () => '/clients/c1/agents/a1' } as UrlTree; + mockRouter.createUrlTree.mockReturnValue(urlTree); + clientsFacadeStub.getClientById$.mockReturnValue( + of({ + id: 'c1', + canManageWorkspaceConfiguration: false, + } as never), + ); + const route = { + paramMap: convertToParamMap({ clientId: 'c1', agentId: 'a1' }), + } as ActivatedRouteSnapshot; + + const result = await runGuard(route); + + expect(result).toBe(urlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['clients', 'c1', 'agents', 'a1']); + }); + + it('redirects to /clients when client or agent id is missing', async () => { + const urlTree = { toString: () => '/clients' } as UrlTree; + mockRouter.createUrlTree.mockReturnValue(urlTree); + const route = { paramMap: convertToParamMap({ clientId: 'c1' }) } as ActivatedRouteSnapshot; + + const result = await runGuard(route); + + expect(result).toBe(urlTree); + expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['clients']); + expect(clientsFacadeStub.loadClient).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.ts b/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.ts new file mode 100644 index 00000000..58df4541 --- /dev/null +++ b/libs/domains/framework/frontend/feature-agent-console/src/lib/guards/config-editor.guard.ts @@ -0,0 +1,32 @@ +import { inject } from '@angular/core'; +import { type ActivatedRouteSnapshot, Router, type CanActivateFn } from '@angular/router'; +// Avoid data-access barrel: it re-exports identity (Keycloak), which breaks lightweight Jest runs. +// eslint-disable-next-line @nx/enforce-module-boundaries +import { ClientsFacade } from '../../../../data-access-agent-console/src/lib/state/clients/clients.facade'; +import { filter, map, take } from 'rxjs'; + +/** + * Ensures the user may open the provider config file editor for the workspace in the URL. + * Redirects to the agent chat route when `canManageWorkspaceConfiguration` is false. + */ +export const configEditorGuard: CanActivateFn = (route: ActivatedRouteSnapshot) => { + const clientsFacade = inject(ClientsFacade); + const router = inject(Router); + const clientId = route.paramMap.get('clientId')?.trim(); + const agentId = route.paramMap.get('agentId')?.trim(); + + if (!clientId || !agentId) { + return router.createUrlTree(['clients']); + } + + clientsFacade.setActiveClient(clientId); + clientsFacade.loadClient(clientId); + + return clientsFacade.getClientById$(clientId).pipe( + filter((client) => client !== null), + take(1), + map((client) => + client!.canManageWorkspaceConfiguration ? true : router.createUrlTree(['clients', clientId, 'agents', agentId]), + ), + ); +};