diff --git a/apps/backend-agent-controller/src/migrations/1774000000000_CreateUserEnvironmentReadStateTable.ts b/apps/backend-agent-controller/src/migrations/1774000000000_CreateUserEnvironmentReadStateTable.ts new file mode 100644 index 00000000..cfdfe941 --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1774000000000_CreateUserEnvironmentReadStateTable.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm'; + +/** + * Per-user read cursors for agent console environment chat notifications. + */ +export class CreateUserEnvironmentReadStateTable1774000000000 implements MigrationInterface { + name = 'CreateUserEnvironmentReadStateTable1774000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'user_environment_read_state', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'user_id', + type: 'varchar', + length: '64', + isNullable: false, + }, + { + name: 'client_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'agent_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'last_read_at', + type: 'timestamptz', + isNullable: true, + }, + { + name: 'last_read_agent_message_id', + type: 'uuid', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + await queryRunner.createUniqueConstraint( + 'user_environment_read_state', + new TableUnique({ + name: 'uq_user_environment_read_state_user_client_agent', + columnNames: ['user_id', 'client_id', 'agent_id'], + }), + ); + + await queryRunner.createIndex( + 'user_environment_read_state', + new TableIndex({ + name: 'IDX_user_environment_read_state_user_id', + columnNames: ['user_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('user_environment_read_state', 'IDX_user_environment_read_state_user_id'); + await queryRunner.dropUniqueConstraint( + 'user_environment_read_state', + 'uq_user_environment_read_state_user_client_agent', + ); + await queryRunner.dropTable('user_environment_read_state'); + } +} diff --git a/apps/backend-agent-controller/src/typeorm.config.ts b/apps/backend-agent-controller/src/typeorm.config.ts index 7a096039..32e94605 100644 --- a/apps/backend-agent-controller/src/typeorm.config.ts +++ b/apps/backend-agent-controller/src/typeorm.config.ts @@ -28,6 +28,7 @@ import { TicketBodyGenerationSessionEntity, TicketCommentEntity, TicketEntity, + UserEnvironmentReadStateEntity, } from '@forepath/framework/backend'; import { CorrelationAwareTypeOrmLogger } from '@forepath/framework/backend/util-http-context'; import { ClientAgentCredentialEntity, ClientEntity, ClientUserEntity, UserEntity } from '@forepath/identity/backend'; @@ -95,6 +96,7 @@ export const typeormConfig: DataSourceOptions = { AgentConsoleRegexFilterRuleEntity, AgentConsoleRegexFilterRuleClientEntity, AgentConsoleRegexFilterRuleSyncTargetEntity, + UserEnvironmentReadStateEntity, ], migrations: [ 'src/migrations/*.js', diff --git a/apps/frontend-agent-console/public/audio/notification-pling.wav b/apps/frontend-agent-console/public/audio/notification-pling.wav new file mode 100644 index 00000000..300eb2b1 Binary files /dev/null and b/apps/frontend-agent-console/public/audio/notification-pling.wav differ diff --git a/docs/agenstra/features/websocket-communication.md b/docs/agenstra/features/websocket-communication.md index 9e877f68..130b7561 100644 --- a/docs/agenstra/features/websocket-communication.md +++ b/docs/agenstra/features/websocket-communication.md @@ -8,10 +8,11 @@ Agenstra uses WebSocket (Socket.IO) for real-time bidirectional communication. T - **Frontend ↔ Controller (`clients` namespace)**: Workspace selection (`setClient`), `forward` to remote agent-managers, and controller-originated ticket hints for chat - **Frontend ↔ Controller (`tickets` namespace)**: Ticket board and automation realtime for subscribers +- **Frontend ↔ Controller (`status` namespace)**: Per-user workspace/environment notification state (git dirty, unread chat) without `setClient` - **Controller ↔ Manager**: Event forwarding to remote agent-managers - **Manager ↔ Agent Containers**: Real-time chat and container communication -On the controller, **`clients`** and **`tickets`** share the same TCP port (`WEBSOCKET_PORT`); namespaces are selected in the Socket.IO client path. +On the controller, **`clients`**, **`tickets`**, **`pages`** (knowledge), and **`status`** share the same TCP port (`WEBSOCKET_PORT`); namespaces are selected in the Socket.IO client path. ## Authentication @@ -23,6 +24,19 @@ WebSocket connections to the controller require authentication. Pass the `Author Unauthenticated connections are rejected with `connect_error` "Unauthorized". The `setClient` operation enforces per-client authorization: only users with access to the requested client (global admin, client creator, or client_users entry) can set that client context. Unauthorized attempts emit an `error` event with message "You do not have access to this client". +### Agent console status (`status` namespace) + +The agent console opens a dedicated Socket.IO connection to **`status`** (derived from `controller.websocketUrl` by replacing `/clients` with `/status`, or via `controller.statusWebsocketUrl`). Handshake auth matches other controller namespaces. + +- **No `setClient`**: the stream is scoped to the authenticated user only. +- **On connect**: server emits **`statusSnapshot`** with all accessible workspaces/environments (git dirty + unread flags). +- **While connected**: server emits **`statusPatch`** for deltas; background polling (`STATUS_POLL_INTERVAL_MS`, default 30s) refreshes git state and catches unread when no `clients` socket is active. Successful VCS mutations proxied through the controller (stage, commit, fetch, pull, push including force, branch operations, conflict resolve, prepare-clean workspace) also emit **`statusPatch`** immediately to every user with access to that workspace. +- **Agent workspace changes**: agent-manager broadcasts **`gitStateChanged`** on the agents namespace after file writes, file-update notifications, workspace-affecting agent tool results, and local VCS/file mutations. The controller **`clients`** gateway listens for **`gitStateChanged`** and **`fileUpdateNotification`**, then pushes **`statusPatch`** on the **`status`** namespace to users with workspace access (same security model as VCS proxy hooks). +- **Client → server**: `markEnvironmentRead` `{ clientId, agentId }`, `setActiveEnvironment` `{ clientId, agentId | null }`. +- **Unread** includes agent chat replies and live ticket automation chat card updates; read cursors persist in `user_environment_read_state` on the controller database. + +See `libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml` and `libs/domains/framework/frontend/data-access-agent-console/docs/notifications-state.mmd`. + ### Billing manager (dashboard status) The billing console can open a second Socket.IO connection to the **billing-manager** status gateway (default namespace `/billing`, separate TCP port from REST). Handshake auth matches HTTP (`Bearer` JWT for users or Keycloak). **Static API key** auth does not receive a user-scoped billing stream; `subscribeDashboardStatus` is rejected with an `error` event, consistent with REST returning "User not authenticated" for API-key-only requests. diff --git a/libs/domains/framework/backend/feature-agent-controller/README.md b/libs/domains/framework/backend/feature-agent-controller/README.md index 4e18b765..792569bf 100644 --- a/libs/domains/framework/backend/feature-agent-controller/README.md +++ b/libs/domains/framework/backend/feature-agent-controller/README.md @@ -356,6 +356,18 @@ See the [OpenAPI specification](./spec/openapi.yaml) for detailed request/respon ## WebSocket Gateway +### Status notifications (`StatusGateway`) + +- **Namespace**: `/status` (`STATUS_WEBSOCKET_NAMESPACE`, default `status`) +- **Port**: same as other controller namespaces (`WEBSOCKET_PORT`, default `8081`) +- **Auth**: handshake `Authorization` (no `setClient`) +- **On connect**: `statusSnapshot` (git dirty + unread per environment for all accessible workspaces) +- **Updates**: `statusPatch`; background poll via `STATUS_POLL_INTERVAL_MS` (default `30000`) +- **Client commands**: `markEnvironmentRead`, `setActiveEnvironment` +- **Diagram**: [agent-console-status-realtime.mmd](./docs/agent-console-status-realtime.mmd) + +### Clients proxy (`ClientsGateway`) + The `ClientsGateway` provides WebSocket-based real-time event forwarding to remote agent-manager WebSocket endpoints: - **Namespace**: `/clients` diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/agent-console-status-realtime.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/agent-console-status-realtime.mmd new file mode 100644 index 00000000..227b728d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/agent-console-status-realtime.mmd @@ -0,0 +1,15 @@ +sequenceDiagram + participant FE as Frontend_notifications + participant SG as StatusGateway + participant Svc as AgentConsoleStatusService + participant DB as controller_DB + participant Mgr as agent_manager_HTTP + + FE->>SG: connect (auth) + SG->>Svc: buildSnapshot(user) + Svc->>DB: read user_environment_read_state + Svc->>Mgr: latest agent message + vcs status + Svc-->>FE: statusSnapshot + + Note over SG,Svc: chatMessage / automation upsert hook + Svc-->>FE: statusPatch (unicast) diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml index 62759091..8738bd38 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml @@ -258,6 +258,20 @@ channels: pagesKnowledgePageActivityCreatedEvent: $ref: '#/components/messages/KnowledgePageActivityCreatedPayload' description: Incremental page activity row for currently open page details. + status/statusSnapshot: + address: status/statusSnapshot + description: | + Full per-user notification state on connect (git dirty + unread per environment, client rollups, spacesHasAttention). + Unicast to the connecting socket only. Namespace **status** (STATUS_WEBSOCKET_NAMESPACE, default "status"). + status/statusPatch: + address: status/statusPatch + description: Incremental notification deltas after connect (unicast). + status/markEnvironmentRead: + address: status/markEnvironmentRead + description: Client marks an environment chat as read; persists user_environment_read_state. + status/setActiveEnvironment: + address: status/setActiveEnvironment + description: Client reports the environment currently in view (suppresses unread for active user). operations: clientSendsSetClient: action: send diff --git a/libs/domains/framework/backend/feature-agent-controller/src/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/index.ts index e87e2f2c..e44a2402 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/index.ts @@ -90,6 +90,7 @@ export * from './lib/entities/ticket-activity.entity'; export * from './lib/entities/ticket-body-generation-session.entity'; export * from './lib/entities/ticket-comment.entity'; export * from './lib/entities/ticket.entity'; +export * from './lib/entities/user-environment-read-state.entity'; export * from './lib/entities/ticket.enums'; export * from './lib/entities/statistics-agent.entity'; export * from './lib/entities/statistics-chat-filter-drop.entity'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/agent-console-status.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/agent-console-status.dto.ts new file mode 100644 index 00000000..54532fb8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/agent-console-status.dto.ts @@ -0,0 +1,37 @@ +export interface EnvironmentStatusPayload { + clientId: string; + agentId: string; + hasUnreadMessages: boolean; + gitDirty: boolean; + gitConflict: boolean; +} + +export interface ClientStatusPayload { + clientId: string; + hasUnreadMessages: boolean; + gitDirty: boolean; +} + +export interface StatusSnapshotPayload { + generatedAt: string; + environments: EnvironmentStatusPayload[]; + clients: ClientStatusPayload[]; + spacesHasAttention: boolean; +} + +export interface StatusPatchPayload { + generatedAt: string; + environments?: EnvironmentStatusPayload[]; + clients?: ClientStatusPayload[]; + spacesHasAttention?: boolean; +} + +export interface MarkEnvironmentReadPayload { + clientId: string; + agentId: string; +} + +export interface SetActiveEnvironmentPayload { + clientId: string | null; + agentId: string | null; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.spec.ts new file mode 100644 index 00000000..c726856e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.spec.ts @@ -0,0 +1,44 @@ +import { UserEnvironmentReadStateEntity } from './user-environment-read-state.entity'; + +describe('UserEnvironmentReadStateEntity', () => { + it('should create an instance', () => { + const entity = new UserEnvironmentReadStateEntity(); + + expect(entity).toBeDefined(); + }); + + it('should have all required properties', () => { + const entity = new UserEnvironmentReadStateEntity(); + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + const updatedAt = new Date('2026-01-02T00:00:00.000Z'); + const lastReadAt = new Date('2026-01-01T12:00:00.000Z'); + + entity.id = 'read-state-uuid'; + entity.userId = 'user-1'; + entity.clientId = 'client-uuid'; + entity.agentId = 'agent-uuid'; + entity.lastReadAt = lastReadAt; + entity.lastReadAgentMessageId = 'message-uuid'; + entity.createdAt = createdAt; + entity.updatedAt = updatedAt; + + expect(entity.id).toBe('read-state-uuid'); + expect(entity.userId).toBe('user-1'); + expect(entity.clientId).toBe('client-uuid'); + expect(entity.agentId).toBe('agent-uuid'); + expect(entity.lastReadAt).toEqual(lastReadAt); + expect(entity.lastReadAgentMessageId).toBe('message-uuid'); + expect(entity.createdAt).toBeInstanceOf(Date); + expect(entity.updatedAt).toBeInstanceOf(Date); + }); + + it('should allow nullable last-read fields', () => { + const entity = new UserEnvironmentReadStateEntity(); + + entity.lastReadAt = null; + entity.lastReadAgentMessageId = null; + + expect(entity.lastReadAt).toBeNull(); + expect(entity.lastReadAgentMessageId).toBeNull(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.ts new file mode 100644 index 00000000..3d91312b --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/user-environment-read-state.entity.ts @@ -0,0 +1,30 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('user_environment_read_state') +@Index('IDX_user_environment_read_state_user_id', ['userId']) +@Index('uq_user_environment_read_state_user_client_agent', ['userId', 'clientId', 'agentId'], { unique: true }) +export class UserEnvironmentReadStateEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'varchar', length: 64, name: 'user_id' }) + userId!: string; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + @Column({ type: 'timestamptz', name: 'last_read_at', nullable: true }) + lastReadAt?: Date | null; + + @Column({ type: 'uuid', name: 'last_read_agent_message_id', nullable: true }) + lastReadAgentMessageId?: string | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts index 1a5e9601..9534a85c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts @@ -8,6 +8,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { AgentConsoleStatusService } from '../services/agent-console-status.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; @@ -163,6 +164,10 @@ describe('ClientsGateway', () => { const mockWorkspaceConfigurationOverridesProxy = { getConfigurationOverrides: jest.fn().mockResolvedValue([]), }; + const mockAgentConsoleStatusService = { + onAgentChatActivity: jest.fn().mockResolvedValue(undefined), + notifyVcsStateChanged: jest.fn().mockResolvedValue(undefined), + }; const createMockSocket = (id = 'socket-1', withUserInfo = true) => { const emitted: Record[] = []; const socket = { @@ -194,6 +199,7 @@ describe('ClientsGateway', () => { { provide: TicketsService, useValue: mockTicketsService }, { provide: KnowledgeTreeService, useValue: mockKnowledgeTreeService }, { provide: AutoContextResolverService, useValue: mockAutoContextResolverService }, + { provide: AgentConsoleStatusService, useValue: mockAgentConsoleStatusService }, { provide: ClientWorkspaceConfigurationOverridesProxyService, useValue: mockWorkspaceConfigurationOverridesProxy, @@ -713,6 +719,8 @@ describe('ClientsGateway', () => { await new Promise((resolve) => setImmediate(resolve)); // Clear previous emit calls to isolate this test (socket.emit as jest.Mock).mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).lastAgentIdBySocket.set(socket.id, 'agent-uuid'); // Simulate fileUpdateNotification event from remote agent-manager gateway const fileUpdateNotification = { success: true, @@ -734,6 +742,79 @@ describe('ClientsGateway', () => { await new Promise((resolve) => setImmediate(resolve)); // Should forward fileUpdateNotification to local socket expect(socket.emit).toHaveBeenCalledWith('fileUpdateNotification', fileUpdateNotification); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); + }); + + it('pushes status patch when remote emits gitStateChanged', async () => { + const socket = createMockSocket(); + const { io } = jest.requireMock('socket.io-client') as { io: jest.Mock }; + const remote = io() as any; + + mockClientsRepository.findByIdOrThrow.mockResolvedValue({ + id: 'client-uuid', + endpoint: 'http://localhost:3100/api', + authenticationType: 'api_key', + apiKey: 'x', + agentWsPort: 8099, + } as any); + await gateway.handleSetClient({ clientId: 'client-uuid' }, socket); + await new Promise((resolve) => setImmediate(resolve)); + remote.triggerEvent('connect'); + await new Promise((resolve) => setImmediate(resolve)); + mockAgentConsoleStatusService.notifyVcsStateChanged.mockClear(); + (socket.emit as jest.Mock).mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).lastAgentIdBySocket.set(socket.id, 'agent-uuid'); + + const onAnyHandler = remote.onAny.mock.calls[0]?.[0]; + + if (onAnyHandler) { + onAnyHandler('gitStateChanged', { + success: true, + data: { agentId: 'agent-uuid', timestamp: new Date().toISOString() }, + timestamp: new Date().toISOString(), + }); + } + + await new Promise((resolve) => setImmediate(resolve)); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); + }); + + it('pushes status patch for gitStateChanged using agentId from payload when lastAgentId is unset', async () => { + const socket = createMockSocket(); + const { io } = jest.requireMock('socket.io-client') as { io: jest.Mock }; + const remote = io() as any; + + mockClientsRepository.findByIdOrThrow.mockResolvedValue({ + id: 'client-uuid', + endpoint: 'http://localhost:3100/api', + authenticationType: 'api_key', + apiKey: 'x', + agentWsPort: 8099, + } as any); + await gateway.handleSetClient({ clientId: 'client-uuid' }, socket); + await new Promise((resolve) => setImmediate(resolve)); + remote.triggerEvent('connect'); + await new Promise((resolve) => setImmediate(resolve)); + mockAgentConsoleStatusService.notifyVcsStateChanged.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).lastAgentIdBySocket.delete(socket.id); + + const onAnyHandler = remote.onAny.mock.calls[0]?.[0]; + + if (onAnyHandler) { + onAnyHandler('gitStateChanged', { + success: true, + data: { agentId: 'agent-from-payload', timestamp: new Date().toISOString() }, + timestamp: new Date().toISOString(), + }); + } + + await new Promise((resolve) => setImmediate(resolve)); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith( + 'client-uuid', + 'agent-from-payload', + ); }); describe('Remote Socket Reconnection', () => { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 0e63fa7a..4d32cd8b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -25,6 +25,7 @@ import { FilterDropDirection } from '../entities/statistics-chat-filter-drop.ent import { FilterFlagDirection } from '../entities/statistics-chat-filter-flag.entity'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ClientsRepository } from '../repositories/clients.repository'; +import { AgentConsoleStatusService } from '../services/agent-console-status.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; @@ -117,6 +118,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat private readonly knowledgeTreeService: KnowledgeTreeService, private readonly autoContextResolverService: AutoContextResolverService, private readonly workspaceConfigurationOverridesProxy: ClientWorkspaceConfigurationOverridesProxyService, + private readonly agentConsoleStatusService: AgentConsoleStatusService, ) {} afterInit(server: Server): void { @@ -386,7 +388,23 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat this.statisticsService .recordChatOutput(currentClientId, lastAgentId, wordCount, charCount, userId) .catch(() => undefined); + + void this.agentConsoleStatusService + .onAgentChatActivity(currentClientId, lastAgentId) + .catch(() => undefined); + } + } else if (event === 'gitStateChanged' && currentClientId && args.length > 0) { + const data = args[0] as { success?: boolean; data?: { agentId?: string } }; + const payload: { agentId?: string } | undefined = data?.success ? data.data : (data as { agentId?: string }); + const agentId = payload?.agentId ?? lastAgentId; + + if (agentId) { + void this.agentConsoleStatusService.notifyVcsStateChanged(currentClientId, agentId).catch(() => undefined); } + } else if (event === 'fileUpdateNotification' && currentClientId && lastAgentId) { + void this.agentConsoleStatusService + .notifyVcsStateChanged(currentClientId, lastAgentId) + .catch(() => undefined); } else if (event === 'messageFilterResult' && currentClientId && lastAgentId && args.length > 0) { const data = args[0] as { success?: boolean; data?: Record }; const payload: Record | undefined = data?.success ? data.data : data; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.spec.ts new file mode 100644 index 00000000..706d843c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.spec.ts @@ -0,0 +1,171 @@ +import { SocketAuthService } from '@forepath/identity/backend'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Server } from 'socket.io'; + +import { AgentConsoleStatusRealtimeService } from '../services/agent-console-status-realtime.service'; +import { AgentConsoleStatusService } from '../services/agent-console-status.service'; + +import { StatusGateway } from './status.gateway'; + +describe('StatusGateway', () => { + let gateway: StatusGateway; + const mockStatusService = { + emitSnapshotToSocket: jest.fn().mockResolvedValue({ + generatedAt: new Date().toISOString(), + environments: [], + clients: [], + spacesHasAttention: false, + }), + runPollForSocket: jest.fn().mockResolvedValue(undefined), + markEnvironmentRead: jest.fn().mockResolvedValue(undefined), + setActiveEnvironment: jest.fn(), + clearSocket: jest.fn(), + }; + const mockRealtime = { + attachServer: jest.fn(), + registerSocket: jest.fn(), + unregisterSocket: jest.fn(), + }; + const mockSocketAuthService = { + validateAndGetUser: jest + .fn() + .mockResolvedValue({ isApiKeyAuth: true, user: { id: 'api-key-user', roles: ['admin'] } }), + }; + let authMiddleware: (socket: { id: string; handshake: object; data: object }, next: (err?: Error) => void) => void; + const createMockSocket = (withUser = true) => { + const socket = { + id: 'socket-1', + emit: jest.fn(), + disconnect: jest.fn(), + data: withUser ? { userInfo: { isApiKeyAuth: true, user: { id: 'api-key-user', roles: ['admin'] } } } : {}, + }; + + return socket as any; + }; + + beforeEach(async () => { + jest.useFakeTimers(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StatusGateway, + { provide: AgentConsoleStatusService, useValue: mockStatusService }, + { provide: AgentConsoleStatusRealtimeService, useValue: mockRealtime }, + { provide: SocketAuthService, useValue: mockSocketAuthService }, + ], + }).compile(); + + gateway = module.get(StatusGateway); + const server = { + use: jest.fn((middleware) => { + authMiddleware = middleware; + }), + } as unknown as Server; + + gateway.afterInit(server); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('attaches auth middleware and rejects invalid tokens', async () => { + mockSocketAuthService.validateAndGetUser.mockResolvedValueOnce(null); + const next = jest.fn(); + const socket = { + id: 'socket-auth', + handshake: { headers: { authorization: 'Bearer bad' }, auth: {} }, + data: {}, + }; + + await authMiddleware(socket, next); + + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('attaches auth middleware and accepts valid tokens', async () => { + const next = jest.fn(); + const socket = { + id: 'socket-auth', + handshake: { headers: {}, auth: { Authorization: 'Bearer good' } }, + data: {}, + }; + + await authMiddleware(socket, next); + + expect(socket.data).toEqual({ + userInfo: { isApiKeyAuth: true, user: { id: 'api-key-user', roles: ['admin'] } }, + }); + expect(next).toHaveBeenCalledWith(); + }); + + it('emits snapshot on connection and starts polling', async () => { + const socket = createMockSocket(); + + await gateway.handleConnection(socket); + expect(mockRealtime.registerSocket).toHaveBeenCalledWith('api-key-user', 'socket-1'); + expect(mockStatusService.emitSnapshotToSocket).toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(30_000); + expect(mockStatusService.runPollForSocket).toHaveBeenCalledWith('socket-1', socket.data.userInfo); + }); + + it('rejects connection without user id', async () => { + const socket = createMockSocket(false); + + await gateway.handleConnection(socket); + expect(socket.emit).toHaveBeenCalledWith('error', expect.objectContaining({ message: 'User not authenticated' })); + expect(socket.disconnect).toHaveBeenCalled(); + }); + + it('cleans up on disconnect', () => { + const socket = createMockSocket(); + + gateway.handleDisconnect(socket); + + expect(mockStatusService.clearSocket).toHaveBeenCalledWith('socket-1'); + expect(mockRealtime.unregisterSocket).toHaveBeenCalledWith('socket-1'); + }); + + it('marks environment read when payload is valid', async () => { + const socket = createMockSocket(); + + await gateway.handleMarkEnvironmentRead({ clientId: 'c1', agentId: 'a1' }, socket); + + expect(mockStatusService.markEnvironmentRead).toHaveBeenCalledWith(socket.data.userInfo, 'c1', 'a1'); + }); + + it('emits errors for unauthorized or invalid markEnvironmentRead payloads', async () => { + const socketWithoutUser = createMockSocket(false); + + await gateway.handleMarkEnvironmentRead({ clientId: 'c1', agentId: 'a1' }, socketWithoutUser); + expect(socketWithoutUser.emit).toHaveBeenCalledWith('error', { message: 'Unauthorized' }); + + const socket = createMockSocket(); + + await gateway.handleMarkEnvironmentRead({ clientId: '', agentId: 'a1' }, socket); + expect(socket.emit).toHaveBeenCalledWith('error', { message: 'clientId and agentId are required' }); + + mockStatusService.markEnvironmentRead.mockRejectedValueOnce(new ForbiddenException('denied')); + await gateway.handleMarkEnvironmentRead({ clientId: 'c1', agentId: 'a1' }, socket); + expect(socket.emit).toHaveBeenCalledWith('error', { message: 'denied' }); + }); + + it('sets active environment for authenticated sockets', () => { + const socket = createMockSocket(); + + gateway.handleSetActiveEnvironment({ clientId: 'c1', agentId: 'a1' }, socket); + expect(mockStatusService.setActiveEnvironment).toHaveBeenCalledWith('socket-1', 'c1', 'a1'); + + gateway.handleSetActiveEnvironment({ clientId: null, agentId: null }, socket); + expect(mockStatusService.setActiveEnvironment).toHaveBeenCalledWith('socket-1', null, null); + }); + + it('rejects setActiveEnvironment without user info', () => { + const socket = createMockSocket(false); + + gateway.handleSetActiveEnvironment({ clientId: 'c1', agentId: 'a1' }, socket); + expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Unauthorized' }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.ts new file mode 100644 index 00000000..6e8ea4a9 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/gateways/status.gateway.ts @@ -0,0 +1,188 @@ +import { SocketAuthService, UserRole, type SocketUserInfo } from '@forepath/identity/backend'; +import { Logger } from '@nestjs/common'; +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + OnGatewayInit, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { MarkEnvironmentReadPayload, SetActiveEnvironmentPayload } from '../dto/agent-console-status.dto'; +import { AgentConsoleStatusRealtimeService } from '../services/agent-console-status-realtime.service'; +import { AgentConsoleStatusService } from '../services/agent-console-status.service'; + +const MIN_POLL_MS = 10_000; +const MAX_POLL_MS = 120_000; + +function defaultPollIntervalMs(): number { + const raw = parseInt(process.env.STATUS_POLL_INTERVAL_MS || '30000', 10); + + if (Number.isNaN(raw)) { + return 30_000; + } + + return Math.min(MAX_POLL_MS, Math.max(MIN_POLL_MS, raw)); +} + +type StatusSocket = Socket & { data: { userInfo?: SocketUserInfo } }; + +@WebSocketGateway(parseInt(process.env.WEBSOCKET_PORT || '8081', 10), { + namespace: process.env.STATUS_WEBSOCKET_NAMESPACE || 'status', + cors: { + origin: process.env.WEBSOCKET_CORS_ORIGIN || '*', + }, +}) +export class StatusGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server!: Server; + + private readonly logger = new Logger(StatusGateway.name); + private readonly pollTimerBySocketId = new Map>(); + private readonly tickInFlight = new Set(); + + constructor( + private readonly socketAuthService: SocketAuthService, + private readonly statusService: AgentConsoleStatusService, + private readonly statusRealtime: AgentConsoleStatusRealtimeService, + ) {} + + afterInit(server: Server): void { + this.statusRealtime.attachServer(server); + server.use(async (socket, next) => { + const authHeader = socket.handshake?.headers?.authorization ?? socket.handshake?.auth?.Authorization; + const userInfo = await this.socketAuthService.validateAndGetUser( + typeof authHeader === 'string' ? authHeader : undefined, + ); + + if (!userInfo) { + this.logger.warn(`Status WS rejected: invalid authorization for socket ${socket.id}`); + next(new Error('Unauthorized')); + + return; + } + + (socket as StatusSocket).data = { userInfo }; + next(); + }); + } + + async handleConnection(socket: Socket): Promise { + const userInfo = (socket as StatusSocket).data?.userInfo; + const userId = userInfo?.user?.id ?? userInfo?.userId; + + if (!userId) { + socket.emit('error', { message: 'User not authenticated' }); + socket.disconnect(); + + return; + } + + this.statusRealtime.registerSocket(userId, socket.id, userInfo.userRole ?? UserRole.USER); + this.logger.debug(`Status client connected: ${socket.id} (user ${userId})`); + + await this.statusService.emitSnapshotToSocket(socket.id, userInfo); + this.startPoll(socket as StatusSocket); + } + + handleDisconnect(socket: Socket): void { + this.clearPollTimer(socket.id); + this.tickInFlight.delete(socket.id); + this.statusService.clearSocket(socket.id); + this.statusRealtime.unregisterSocket(socket.id); + this.logger.debug(`Status client disconnected: ${socket.id}`); + } + + @SubscribeMessage('markEnvironmentRead') + async handleMarkEnvironmentRead( + @MessageBody() body: MarkEnvironmentReadPayload, + @ConnectedSocket() socket: Socket, + ): Promise { + const userInfo = (socket as StatusSocket).data?.userInfo; + + if (!userInfo) { + socket.emit('error', { message: 'Unauthorized' }); + + return; + } + + const clientId = body?.clientId; + const agentId = body?.agentId; + + if (!clientId || !agentId) { + socket.emit('error', { message: 'clientId and agentId are required' }); + + return; + } + + try { + await this.statusService.markEnvironmentRead(userInfo, clientId, agentId); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unable to mark environment read'; + + socket.emit('error', { message }); + } + } + + @SubscribeMessage('setActiveEnvironment') + handleSetActiveEnvironment( + @MessageBody() body: SetActiveEnvironmentPayload, + @ConnectedSocket() socket: Socket, + ): void { + const userInfo = (socket as StatusSocket).data?.userInfo; + + if (!userInfo) { + socket.emit('error', { message: 'Unauthorized' }); + + return; + } + + this.statusService.setActiveEnvironment(socket.id, body?.clientId ?? null, body?.agentId ?? null); + } + + private startPoll(socket: StatusSocket): void { + this.clearPollTimer(socket.id); + const intervalMs = defaultPollIntervalMs(); + const timer = setInterval(() => { + void this.runPollTick(socket); + }, intervalMs); + + this.pollTimerBySocketId.set(socket.id, timer); + } + + private clearPollTimer(socketId: string): void { + const existing = this.pollTimerBySocketId.get(socketId); + + if (existing) { + clearInterval(existing); + this.pollTimerBySocketId.delete(socketId); + } + } + + private async runPollTick(socket: StatusSocket): Promise { + if (this.tickInFlight.has(socket.id)) { + return; + } + + this.tickInFlight.add(socket.id); + const userInfo = socket.data?.userInfo; + + if (!userInfo) { + this.tickInFlight.delete(socket.id); + + return; + } + + try { + await this.statusService.runPollForSocket(socket.id, userInfo); + } catch (error: unknown) { + this.logger.warn(`Status poll failed for socket ${socket.id}: ${(error as Error).message}`); + } finally { + this.tickInFlight.delete(socket.id); + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index 3497ed38..9491c40b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -39,6 +39,7 @@ import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; import { TicketEntity } from '../entities/ticket.entity'; +import { UserEnvironmentReadStateEntity } from '../entities/user-environment-read-state.entity'; import { ClientsGateway } from '../gateways/clients.gateway'; import { ClientsRepository } from '../repositories/clients.repository'; import { AutonomousTicketScheduler } from '../services/autonomous-ticket.scheduler'; @@ -177,6 +178,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgePageActivityEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(UserEnvironmentReadStateEntity)) + .useValue(mockRepository) .overrideProvider(UsersRepository) .useValue(mockRepository) .overrideProvider(AutonomousTicketScheduler) diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts index b222ea42..5ecd2eaa 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -43,14 +43,20 @@ import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; import { TicketEntity } from '../entities/ticket.entity'; +import { UserEnvironmentReadStateEntity } from '../entities/user-environment-read-state.entity'; import { ClientsGateway } from '../gateways/clients.gateway'; import { KnowledgeBoardGateway } from '../gateways/knowledge-board.gateway'; +import { StatusGateway } from '../gateways/status.gateway'; import { TicketsBoardGateway } from '../gateways/tickets-board.gateway'; import { DigitalOceanProvider } from '../providers/provisioning/digital-ocean.provider'; import { HetznerProvider } from '../providers/provisioning/hetzner.provider'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; import { ClientsRepository } from '../repositories/clients.repository'; import { ProvisioningReferencesRepository } from '../repositories/provisioning-references.repository'; +import { TicketAutomationRunsStatusRepository } from '../repositories/ticket-automation-runs-status.repository'; +import { UserEnvironmentReadStateRepository } from '../repositories/user-environment-read-state.repository'; +import { AgentConsoleStatusRealtimeService } from '../services/agent-console-status-realtime.service'; +import { AgentConsoleStatusService } from '../services/agent-console-status.service'; import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { AutonomousRunOrchestratorService } from '../services/autonomous-run-orchestrator.service'; import { AutonomousTicketScheduler } from '../services/autonomous-ticket.scheduler'; @@ -58,6 +64,7 @@ import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.se import { ClientAgentDeploymentsProxyService } from '../services/client-agent-deployments-proxy.service'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; +import { ClientAgentMessagesProxyService } from '../services/client-agent-messages-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.service'; import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; @@ -107,6 +114,7 @@ const authMethod = getAuthenticationMethod(); KnowledgeNodeEmbeddingEntity, KnowledgePageActivityEntity, KnowledgeRelationEntity, + UserEnvironmentReadStateEntity, ]), StatisticsModule, forwardRef(() => FilterRulesModule), @@ -155,6 +163,11 @@ const authMethod = getAuthenticationMethod(); ClientAgentCredentialsRepository, ClientAgentCredentialsService, SocketAuthService, + AgentConsoleStatusRealtimeService, + AgentConsoleStatusService, + ClientAgentMessagesProxyService, + UserEnvironmentReadStateRepository, + TicketAutomationRunsStatusRepository, ClientsGateway, TicketBoardRealtimeService, KnowledgeBoardRealtimeService, @@ -162,6 +175,7 @@ const authMethod = getAuthenticationMethod(); TicketAutomationChatSyncService, TicketsBoardGateway, KnowledgeBoardGateway, + StatusGateway, ProvisioningService, ProvisioningProviderFactory, ProvisioningReferencesRepository, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.spec.ts new file mode 100644 index 00000000..34f50488 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.spec.ts @@ -0,0 +1,83 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; + +import { TicketAutomationRunsStatusRepository } from './ticket-automation-runs-status.repository'; + +describe('TicketAutomationRunsStatusRepository', () => { + let repository: TicketAutomationRunsStatusRepository; + const mockQb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + getRawMany: jest.fn(), + }; + const mockTypeOrmRepository = { + createQueryBuilder: jest.fn().mockReturnValue(mockQb), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TicketAutomationRunsStatusRepository, + { + provide: getRepositoryToken(TicketAutomationRunEntity), + useValue: mockTypeOrmRepository, + }, + ], + }).compile(); + + repository = module.get(TicketAutomationRunsStatusRepository); + jest.clearAllMocks(); + mockTypeOrmRepository.createQueryBuilder.mockReturnValue(mockQb); + }); + + describe('findLatestUpdatedAtByAgent', () => { + it('returns null when no runs', async () => { + mockQb.getRawOne.mockResolvedValue({ maxUpdatedAt: null }); + + const result = await repository.findLatestUpdatedAtByAgent('client-1', 'agent-1'); + + expect(result).toBeNull(); + }); + + it('returns Date from raw row', async () => { + const date = new Date('2026-01-15T12:00:00.000Z'); + + mockQb.getRawOne.mockResolvedValue({ maxUpdatedAt: date }); + + const result = await repository.findLatestUpdatedAtByAgent('client-1', 'agent-1'); + + expect(result).toEqual(date); + }); + + it('parses string timestamp', async () => { + mockQb.getRawOne.mockResolvedValue({ maxUpdatedAt: '2026-01-15T12:00:00.000Z' }); + + const result = await repository.findLatestUpdatedAtByAgent('client-1', 'agent-1'); + + expect(result).toEqual(new Date('2026-01-15T12:00:00.000Z')); + }); + }); + + describe('findLatestUpdatedAtByClient', () => { + it('builds map keyed by agent id', async () => { + const d1 = new Date('2026-01-01T00:00:00.000Z'); + const d2 = new Date('2026-01-02T00:00:00.000Z'); + + mockQb.getRawMany.mockResolvedValue([ + { agentId: 'agent-1', maxUpdatedAt: d1 }, + { agentId: 'agent-2', maxUpdatedAt: d2 }, + ]); + + const result = await repository.findLatestUpdatedAtByClient('client-1'); + + expect(result.get('agent-1')).toEqual(d1); + expect(result.get('agent-2')).toEqual(d2); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.ts new file mode 100644 index 00000000..ec73f674 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/ticket-automation-runs-status.repository.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; + +@Injectable() +export class TicketAutomationRunsStatusRepository { + constructor( + @InjectRepository(TicketAutomationRunEntity) + private readonly repository: Repository, + ) {} + + async findLatestUpdatedAtByAgent(clientId: string, agentId: string): Promise { + const row = await this.repository + .createQueryBuilder('r') + .select('MAX(r.updated_at)', 'maxUpdatedAt') + .where('r.client_id = :clientId', { clientId }) + .andWhere('r.agent_id = :agentId', { agentId }) + .getRawOne<{ maxUpdatedAt: Date | string | null }>(); + + if (!row?.maxUpdatedAt) { + return null; + } + + return row.maxUpdatedAt instanceof Date ? row.maxUpdatedAt : new Date(row.maxUpdatedAt); + } + + async findLatestUpdatedAtByClient(clientId: string): Promise> { + const rows = await this.repository + .createQueryBuilder('r') + .select('r.agent_id', 'agentId') + .addSelect('MAX(r.updated_at)', 'maxUpdatedAt') + .where('r.client_id = :clientId', { clientId }) + .groupBy('r.agent_id') + .getRawMany<{ agentId: string; maxUpdatedAt: Date | string }>(); + const map = new Map(); + + for (const row of rows) { + if (row.agentId && row.maxUpdatedAt) { + map.set(row.agentId, row.maxUpdatedAt instanceof Date ? row.maxUpdatedAt : new Date(row.maxUpdatedAt)); + } + } + + return map; + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.spec.ts new file mode 100644 index 00000000..82b754a8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.spec.ts @@ -0,0 +1,120 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { UserEnvironmentReadStateEntity } from '../entities/user-environment-read-state.entity'; + +import { UserEnvironmentReadStateRepository } from './user-environment-read-state.repository'; + +describe('UserEnvironmentReadStateRepository', () => { + let repository: UserEnvironmentReadStateRepository; + const mockRow: UserEnvironmentReadStateEntity = { + id: 'state-1', + userId: 'user-1', + clientId: 'client-1', + agentId: 'agent-1', + lastReadAt: new Date('2026-01-01T00:00:00.000Z'), + lastReadAgentMessageId: 'msg-1', + createdAt: new Date(), + updatedAt: new Date(), + }; + const mockTypeOrmRepository = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserEnvironmentReadStateRepository, + { + provide: getRepositoryToken(UserEnvironmentReadStateEntity), + useValue: mockTypeOrmRepository, + }, + ], + }).compile(); + + repository = module.get(UserEnvironmentReadStateRepository); + jest.clearAllMocks(); + }); + + describe('findByUserId', () => { + it('returns rows for user', async () => { + mockTypeOrmRepository.find.mockResolvedValue([mockRow]); + + const result = await repository.findByUserId('user-1'); + + expect(result).toEqual([mockRow]); + expect(mockTypeOrmRepository.find).toHaveBeenCalledWith({ where: { userId: 'user-1' } }); + }); + }); + + describe('findOne', () => { + it('returns matching row', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue(mockRow); + + const result = await repository.findOne('user-1', 'client-1', 'agent-1'); + + expect(result).toEqual(mockRow); + }); + }); + + describe('upsertReadState', () => { + it('updates existing row', async () => { + const updatedAt = new Date('2026-02-01T00:00:00.000Z'); + + mockTypeOrmRepository.findOne.mockResolvedValue({ ...mockRow }); + mockTypeOrmRepository.save.mockImplementation(async (row) => row); + + const result = await repository.upsertReadState({ + userId: 'user-1', + clientId: 'client-1', + agentId: 'agent-1', + lastReadAt: updatedAt, + lastReadAgentMessageId: 'msg-2', + }); + + expect(result.lastReadAt).toEqual(updatedAt); + expect(result.lastReadAgentMessageId).toBe('msg-2'); + expect(mockTypeOrmRepository.create).not.toHaveBeenCalled(); + }); + + it('creates row when missing', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue(null); + mockTypeOrmRepository.create.mockReturnValue(mockRow); + mockTypeOrmRepository.save.mockResolvedValue(mockRow); + + const result = await repository.upsertReadState({ + userId: 'user-1', + clientId: 'client-1', + agentId: 'agent-1', + lastReadAt: new Date(), + }); + + expect(result).toEqual(mockRow); + expect(mockTypeOrmRepository.create).toHaveBeenCalled(); + }); + }); + + describe('findByUserAndClientIds', () => { + it('returns empty array when no client ids', async () => { + const result = await repository.findByUserAndClientIds('user-1', []); + + expect(result).toEqual([]); + expect(mockTypeOrmRepository.find).not.toHaveBeenCalled(); + }); + + it('queries with In filter', async () => { + mockTypeOrmRepository.find.mockResolvedValue([mockRow]); + + await repository.findByUserAndClientIds('user-1', ['client-1', 'client-2']); + + expect(mockTypeOrmRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: 'user-1' }), + }), + ); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.ts new file mode 100644 index 00000000..defdbb48 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/user-environment-read-state.repository.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { UserEnvironmentReadStateEntity } from '../entities/user-environment-read-state.entity'; + +@Injectable() +export class UserEnvironmentReadStateRepository { + constructor( + @InjectRepository(UserEnvironmentReadStateEntity) + private readonly repository: Repository, + ) {} + + async findByUserId(userId: string): Promise { + return await this.repository.find({ where: { userId } }); + } + + async findOne(userId: string, clientId: string, agentId: string): Promise { + return await this.repository.findOne({ where: { userId, clientId, agentId } }); + } + + async upsertReadState(params: { + userId: string; + clientId: string; + agentId: string; + lastReadAt: Date; + lastReadAgentMessageId?: string | null; + }): Promise { + const existing = await this.findOne(params.userId, params.clientId, params.agentId); + + if (existing) { + existing.lastReadAt = params.lastReadAt; + existing.lastReadAgentMessageId = params.lastReadAgentMessageId ?? null; + + return await this.repository.save(existing); + } + + const created = this.repository.create({ + userId: params.userId, + clientId: params.clientId, + agentId: params.agentId, + lastReadAt: params.lastReadAt, + lastReadAgentMessageId: params.lastReadAgentMessageId ?? null, + }); + + return await this.repository.save(created); + } + + async findByUserAndClientIds(userId: string, clientIds: string[]): Promise { + if (clientIds.length === 0) { + return []; + } + + return await this.repository.find({ + where: { userId, clientId: In(clientIds) }, + }); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.spec.ts new file mode 100644 index 00000000..cba13bf0 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.spec.ts @@ -0,0 +1,82 @@ +import { UserRole } from '@forepath/identity/backend'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Server } from 'socket.io'; + +import { AgentConsoleStatusRealtimeService } from './agent-console-status-realtime.service'; + +describe('AgentConsoleStatusRealtimeService', () => { + let service: AgentConsoleStatusRealtimeService; + const mockEmit = jest.fn(); + const mockTo = jest.fn().mockReturnValue({ emit: mockEmit }); + const mockServer = { to: mockTo } as unknown as Server; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AgentConsoleStatusRealtimeService], + }).compile(); + + service = module.get(AgentConsoleStatusRealtimeService); + service.attachServer(mockServer); + jest.clearAllMocks(); + }); + + it('registers and unregisters sockets per user', () => { + service.registerSocket('user-1', 'socket-a'); + service.registerSocket('user-1', 'socket-b'); + + expect(service.getUserIdForSocket('socket-a')).toBe('user-1'); + + service.unregisterSocket('socket-a'); + expect(service.getUserIdForSocket('socket-a')).toBeUndefined(); + expect(service.getUserIdForSocket('socket-b')).toBe('user-1'); + + service.unregisterSocket('socket-b'); + expect(service.getUserIdForSocket('socket-b')).toBeUndefined(); + }); + + it('emitToUser sends event to all sockets for user', () => { + service.registerSocket('user-1', 'socket-a'); + service.registerSocket('user-1', 'socket-b'); + + service.emitToUser('user-1', 'statusPatch', { ok: true }); + + expect(mockTo).toHaveBeenCalledWith('socket-a'); + expect(mockTo).toHaveBeenCalledWith('socket-b'); + expect(mockEmit).toHaveBeenCalledWith('statusPatch', { ok: true }); + expect(mockEmit).toHaveBeenCalledTimes(2); + }); + + it('emitToUser is no-op when server not attached', () => { + const detached = new AgentConsoleStatusRealtimeService(); + + detached.registerSocket('user-1', 'socket-a'); + detached.emitToUser('user-1', 'statusPatch', {}); + + expect(mockEmit).not.toHaveBeenCalled(); + }); + + it('emitToUsers fans out to each user', () => { + service.registerSocket('user-1', 's1'); + service.registerSocket('user-2', 's2'); + + service.emitToUsers(['user-1', 'user-2'], 'statusPatch', { x: 1 }); + + expect(mockTo).toHaveBeenCalledWith('s1'); + expect(mockTo).toHaveBeenCalledWith('s2'); + }); + + it('tracks connected user ids and roles until last socket disconnects', () => { + service.registerSocket('admin-1', 's1', UserRole.ADMIN); + service.registerSocket('admin-1', 's2', UserRole.ADMIN); + + expect(service.getConnectedUserIds()).toEqual(['admin-1']); + expect(service.getUserRole('admin-1')).toBe(UserRole.ADMIN); + + service.unregisterSocket('s1'); + expect(service.getConnectedUserIds()).toEqual(['admin-1']); + + service.unregisterSocket('s2'); + expect(service.getConnectedUserIds()).toEqual([]); + expect(service.getUserRole('admin-1')).toBeUndefined(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.ts new file mode 100644 index 00000000..22085ca7 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status-realtime.service.ts @@ -0,0 +1,84 @@ +import { UserRole } from '@forepath/identity/backend'; +import { Injectable, Logger } from '@nestjs/common'; +import { Server } from 'socket.io'; + +@Injectable() +export class AgentConsoleStatusRealtimeService { + private readonly logger = new Logger(AgentConsoleStatusRealtimeService.name); + private server: Server | null = null; + private readonly socketsByUserId = new Map>(); + private readonly socketIdToUserId = new Map(); + private readonly userRoleByUserId = new Map(); + + attachServer(server: Server): void { + this.server = server; + } + + registerSocket(userId: string, socketId: string, userRole: UserRole = UserRole.USER): void { + let set = this.socketsByUserId.get(userId); + + if (!set) { + set = new Set(); + this.socketsByUserId.set(userId, set); + } + + set.add(socketId); + this.socketIdToUserId.set(socketId, userId); + this.userRoleByUserId.set(userId, userRole); + } + + getConnectedUserIds(): string[] { + return [...this.socketsByUserId.keys()]; + } + + getUserRole(userId: string): UserRole | undefined { + return this.userRoleByUserId.get(userId); + } + + getUserIdForSocket(socketId: string): string | undefined { + return this.socketIdToUserId.get(socketId); + } + + unregisterSocket(socketId: string): void { + const userId = this.socketIdToUserId.get(socketId); + + if (!userId) { + return; + } + + const set = this.socketsByUserId.get(userId); + + if (set) { + set.delete(socketId); + + if (set.size === 0) { + this.socketsByUserId.delete(userId); + this.userRoleByUserId.delete(userId); + } + } + + this.socketIdToUserId.delete(socketId); + } + + emitToUser(userId: string, event: string, payload: unknown): void { + if (!this.server) { + return; + } + + const socketIds = this.socketsByUserId.get(userId); + + if (!socketIds?.size) { + return; + } + + for (const socketId of socketIds) { + this.server.to(socketId).emit(event, payload); + } + } + + emitToUsers(userIds: string[], event: string, payload: unknown): void { + for (const userId of userIds) { + this.emitToUser(userId, event, payload); + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.spec.ts new file mode 100644 index 00000000..f815ab59 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.spec.ts @@ -0,0 +1,280 @@ +import { ClientUsersRepository, UserRole } from '@forepath/identity/backend'; +import { ForbiddenException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { TicketAutomationRunsStatusRepository } from '../repositories/ticket-automation-runs-status.repository'; +import { UserEnvironmentReadStateRepository } from '../repositories/user-environment-read-state.repository'; + +import { AgentConsoleStatusRealtimeService } from './agent-console-status-realtime.service'; +import { AgentConsoleStatusService } from './agent-console-status.service'; +import { ClientAgentMessagesProxyService } from './client-agent-messages-proxy.service'; +import { ClientAgentProxyService } from './client-agent-proxy.service'; +import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { ClientsService } from './clients.service'; + +describe('AgentConsoleStatusService', () => { + let service: AgentConsoleStatusService; + const clientsService = { + getAccessibleClientIds: jest.fn().mockResolvedValue(['client-1']), + }; + const clientsRepository = { + findById: jest.fn().mockResolvedValue({ id: 'client-1', userId: 'user-1' }), + }; + const clientUsersRepository = { + findByClientId: jest.fn().mockResolvedValue([]), + findUserClientAccess: jest.fn().mockResolvedValue({ role: 'user' }), + }; + const readStateRepository = { + findByUserAndClientIds: jest.fn().mockResolvedValue([]), + findOne: jest.fn().mockResolvedValue(null), + upsertReadState: jest.fn().mockResolvedValue({}), + }; + const automationRunsStatusRepository = { + findLatestUpdatedAtByClient: jest.fn().mockResolvedValue(new Map()), + }; + const messagesProxy = { + getLatestAgentMessage: jest.fn().mockResolvedValue({ + id: 'msg-1', + createdAt: new Date('2026-01-02T00:00:00.000Z').toISOString(), + }), + }; + const vcsProxy = { + getStatus: jest.fn().mockResolvedValue({ + isClean: false, + hasUnpushedCommits: false, + files: [{ status: 'M', path: 'a.ts' }], + }), + }; + const agentProxy = { + getClientAgents: jest.fn().mockResolvedValue([{ id: 'agent-1' }]), + }; + const realtime = { + emitToUser: jest.fn(), + getUserIdForSocket: jest.fn(), + getConnectedUserIds: jest.fn().mockReturnValue([]), + getUserRole: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentConsoleStatusService, + { provide: ClientsService, useValue: clientsService }, + { provide: ClientsRepository, useValue: clientsRepository }, + { provide: ClientUsersRepository, useValue: clientUsersRepository }, + { provide: UserEnvironmentReadStateRepository, useValue: readStateRepository }, + { provide: TicketAutomationRunsStatusRepository, useValue: automationRunsStatusRepository }, + { provide: ClientAgentMessagesProxyService, useValue: messagesProxy }, + { provide: ClientAgentVcsProxyService, useValue: vcsProxy }, + { provide: ClientAgentProxyService, useValue: agentProxy }, + { provide: AgentConsoleStatusRealtimeService, useValue: realtime }, + ], + }).compile(); + + service = module.get(AgentConsoleStatusService); + jest.clearAllMocks(); + agentProxy.getClientAgents.mockResolvedValue([{ id: 'agent-1' }]); + clientsRepository.findById.mockResolvedValue({ id: 'client-1', userId: 'user-1' }); + clientUsersRepository.findByClientId.mockResolvedValue([]); + }); + + it('builds snapshot with unread and git dirty', async () => { + const snapshot = await service.buildSnapshotForUser({ + isApiKeyAuth: false, + userId: 'user-1', + userRole: UserRole.USER, + user: { id: 'user-1', roles: [] }, + }); + + expect(snapshot.environments).toHaveLength(1); + expect(snapshot.environments[0]).toMatchObject({ + clientId: 'client-1', + agentId: 'agent-1', + hasUnreadMessages: true, + gitDirty: true, + }); + expect(snapshot.spacesHasAttention).toBe(true); + }); + + it('marks environment read and emits patch', async () => { + jest.mocked(readStateRepository.upsertReadState).mockResolvedValue({ + userId: 'user-1', + clientId: 'client-1', + agentId: 'agent-1', + lastReadAt: new Date(), + } as never); + messagesProxy.getLatestAgentMessage.mockResolvedValue({ + id: 'msg-1', + createdAt: new Date().toISOString(), + }); + + await service.markEnvironmentRead( + { isApiKeyAuth: false, userId: 'user-1', user: { id: 'user-1', roles: [] } }, + 'client-1', + 'agent-1', + ); + + expect(readStateRepository.upsertReadState).toHaveBeenCalled(); + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusPatch', expect.any(Object)); + }); + + it('notifyVcsStateChanged emits status patches to users with client access', async () => { + vcsProxy.getStatus.mockResolvedValue({ + isClean: true, + hasUnpushedCommits: false, + files: [], + }); + + await service.notifyVcsStateChanged('client-1', 'agent-1'); + + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusPatch', expect.any(Object)); + }); + + it('notifyVcsStateChanged includes connected global admins with client access', async () => { + clientUsersRepository.findByClientId.mockResolvedValue([{ userId: 'member-2' }]); + realtime.getConnectedUserIds.mockReturnValue(['admin-user']); + realtime.getUserRole.mockReturnValue(UserRole.ADMIN); + vcsProxy.getStatus.mockResolvedValue({ + isClean: false, + hasUnpushedCommits: false, + files: [{ status: 'M', path: 'x.ts' }], + }); + + await service.notifyVcsStateChanged('client-1', 'agent-1'); + + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusPatch', expect.any(Object)); + expect(realtime.emitToUser).toHaveBeenCalledWith('member-2', 'statusPatch', expect.any(Object)); + expect(realtime.emitToUser).toHaveBeenCalledWith('admin-user', 'statusPatch', expect.any(Object)); + }); + + it('returns empty snapshot when user id cannot be resolved', async () => { + const snapshot = await service.buildSnapshotForUser({ isApiKeyAuth: true }); + + expect(snapshot).toEqual({ + generatedAt: expect.any(String), + environments: [], + clients: [], + spacesHasAttention: false, + }); + }); + + it('tracks active environment per socket and clears on disconnect', () => { + service.setActiveEnvironment('socket-1', 'client-1', 'agent-1'); + expect(service.isActiveEnvironmentForSocket('socket-1', 'client-1', 'agent-1')).toBe(true); + + service.setActiveEnvironment('socket-1', null, null); + expect(service.isActiveEnvironmentForSocket('socket-1', 'client-1', 'agent-1')).toBe(false); + + service.setActiveEnvironment('socket-1', 'client-1', 'agent-1'); + service.clearSocket('socket-1'); + expect(service.isActiveEnvironmentForSocket('socket-1', 'client-1', 'agent-1')).toBe(false); + }); + + it('emits snapshot to socket and stores last snapshot for polling', async () => { + const snapshot = await service.emitSnapshotToSocket('socket-1', { + isApiKeyAuth: false, + userId: 'user-1', + user: { id: 'user-1', roles: [] }, + }); + + expect(snapshot.environments).toHaveLength(1); + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusSnapshot', snapshot); + + vcsProxy.getStatus.mockResolvedValue({ + isClean: true, + hasUnpushedCommits: false, + files: [], + }); + jest.clearAllMocks(); + + await service.runPollForSocket('socket-1', { + isApiKeyAuth: false, + userId: 'user-1', + user: { id: 'user-1', roles: [] }, + }); + + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusPatch', expect.any(Object)); + }); + + it('skips markEnvironmentRead when user id is missing', async () => { + await service.markEnvironmentRead({ isApiKeyAuth: true }, 'client-1', 'agent-1'); + + expect(readStateRepository.upsertReadState).not.toHaveBeenCalled(); + }); + + it('rejects markEnvironmentRead when agent is not accessible', async () => { + agentProxy.getClientAgents.mockResolvedValue([{ id: 'other-agent' }]); + + await expect( + service.markEnvironmentRead( + { isApiKeyAuth: false, userId: 'user-1', user: { id: 'user-1', roles: [] } }, + 'client-1', + 'agent-1', + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('notifies users on agent chat activity and auto-reads active environment', async () => { + realtime.getUserIdForSocket.mockReturnValue('user-1'); + service.setActiveEnvironment('socket-1', 'client-1', 'agent-1'); + + await service.onAgentChatActivity('client-1', 'agent-1', new Date('2026-01-03T00:00:00.000Z')); + + expect(readStateRepository.upsertReadState).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + clientId: 'client-1', + agentId: 'agent-1', + lastReadAt: new Date('2026-01-03T00:00:00.000Z'), + }), + ); + expect(realtime.emitToUser).toHaveBeenCalledWith('user-1', 'statusPatch', expect.any(Object)); + }); + + it('maps git conflict and unpushed commits in snapshot', async () => { + vcsProxy.getStatus.mockResolvedValue({ + isClean: true, + hasUnpushedCommits: true, + files: [{ status: 'UU', path: 'conflict.ts' }], + }); + messagesProxy.getLatestAgentMessage.mockResolvedValue(null); + readStateRepository.findByUserAndClientIds.mockResolvedValue([]); + + const snapshot = await service.buildSnapshotForUser({ + isApiKeyAuth: false, + userId: 'user-1', + userRole: UserRole.USER, + user: { id: 'user-1', roles: [] }, + }); + + expect(snapshot.environments[0]).toMatchObject({ + gitDirty: true, + gitConflict: true, + hasUnreadMessages: false, + }); + }); + + it('skips clients when agent list cannot be loaded', async () => { + agentProxy.getClientAgents.mockRejectedValue(new Error('offline')); + + const snapshot = await service.buildSnapshotForUser({ + isApiKeyAuth: false, + userId: 'user-1', + userRole: UserRole.USER, + user: { id: 'user-1', roles: [] }, + }); + + expect(snapshot.environments).toEqual([]); + }); + + it('notifies workspace owner and members on automation chat activity', async () => { + clientsRepository.findById.mockResolvedValue({ id: 'client-1', userId: 'owner-1' }); + clientUsersRepository.findByClientId.mockResolvedValue([{ userId: 'member-1' }]); + + await service.onAutomationChatActivity('client-1', 'agent-1'); + + expect(realtime.emitToUser).toHaveBeenCalledWith('owner-1', 'statusPatch', expect.any(Object)); + expect(realtime.emitToUser).toHaveBeenCalledWith('member-1', 'statusPatch', expect.any(Object)); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts new file mode 100644 index 00000000..41c185cf --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts @@ -0,0 +1,463 @@ +import { GitStatusDto } from '@forepath/framework/backend/feature-agent-manager'; +import { + checkClientAccess, + ClientUsersRepository, + ensureClientAccess, + type SocketUserInfo, + buildRequestFromSocketUser, + UserRole, +} from '@forepath/identity/backend'; +import { ForbiddenException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; + +import { + ClientStatusPayload, + EnvironmentStatusPayload, + StatusPatchPayload, + StatusSnapshotPayload, +} from '../dto/agent-console-status.dto'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { TicketAutomationRunsStatusRepository } from '../repositories/ticket-automation-runs-status.repository'; +import { UserEnvironmentReadStateRepository } from '../repositories/user-environment-read-state.repository'; + +import { AgentConsoleStatusRealtimeService } from './agent-console-status-realtime.service'; +import { ClientAgentMessagesProxyService } from './client-agent-messages-proxy.service'; +import { ClientAgentProxyService } from './client-agent-proxy.service'; +import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; +import { ClientsService } from './clients.service'; + +const DEFAULT_VCS_CONCURRENCY = 3; + +function resolveUserId(userInfo: SocketUserInfo): string | null { + return userInfo.user?.id ?? userInfo.userId ?? null; +} + +function mapGitStatus(status: GitStatusDto): { gitDirty: boolean; gitConflict: boolean } { + const hasConflicts = status.files.some((f) => f.status.includes('U')); + const gitDirty = !status.isClean || status.hasUnpushedCommits; + + return { gitDirty, gitConflict: hasConflicts }; +} + +function rollupClients(environments: EnvironmentStatusPayload[]): ClientStatusPayload[] { + const byClient = new Map(); + + for (const env of environments) { + const existing = byClient.get(env.clientId) ?? { + clientId: env.clientId, + hasUnreadMessages: false, + gitDirty: false, + }; + + existing.hasUnreadMessages = existing.hasUnreadMessages || env.hasUnreadMessages; + existing.gitDirty = existing.gitDirty || env.gitDirty; + byClient.set(env.clientId, existing); + } + + return [...byClient.values()]; +} + +function spacesHasAttention(clients: ClientStatusPayload[]): boolean { + return clients.some((c) => c.hasUnreadMessages || c.gitDirty); +} + +@Injectable() +export class AgentConsoleStatusService { + private readonly logger = new Logger(AgentConsoleStatusService.name); + private readonly activeEnvironmentBySocketId = new Map(); + private readonly lastSnapshotBySocketId = new Map(); + + constructor( + private readonly clientsService: ClientsService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + private readonly readStateRepository: UserEnvironmentReadStateRepository, + private readonly automationRunsStatusRepository: TicketAutomationRunsStatusRepository, + private readonly messagesProxy: ClientAgentMessagesProxyService, + @Inject(forwardRef(() => ClientAgentVcsProxyService)) + private readonly vcsProxy: ClientAgentVcsProxyService, + private readonly agentProxy: ClientAgentProxyService, + private readonly realtime: AgentConsoleStatusRealtimeService, + ) {} + + setActiveEnvironment(socketId: string, clientId: string | null, agentId: string | null): void { + if (!clientId || !agentId) { + this.activeEnvironmentBySocketId.set(socketId, null); + + return; + } + + this.activeEnvironmentBySocketId.set(socketId, { clientId, agentId }); + } + + clearSocket(socketId: string): void { + this.activeEnvironmentBySocketId.delete(socketId); + this.lastSnapshotBySocketId.delete(socketId); + } + + async assertEnvironmentAccess(userInfo: SocketUserInfo, clientId: string, agentId: string): Promise { + await ensureClientAccess( + this.clientsRepository, + this.clientUsersRepository, + clientId, + buildRequestFromSocketUser(userInfo), + ); + + const agents = await this.agentProxy.getClientAgents(clientId, 1000, 0); + + if (!agents.some((a) => a.id === agentId)) { + throw new ForbiddenException('You do not have access to this environment'); + } + } + + async markEnvironmentRead(userInfo: SocketUserInfo, clientId: string, agentId: string): Promise { + const userId = resolveUserId(userInfo); + + if (!userId) { + return; + } + + await this.assertEnvironmentAccess(userInfo, clientId, agentId); + + const latest = await this.messagesProxy.getLatestAgentMessage(clientId, agentId); + + await this.readStateRepository.upsertReadState({ + userId, + clientId, + agentId, + lastReadAt: new Date(), + lastReadAgentMessageId: latest?.id ?? null, + }); + + await this.pushPatchForUser(userId, clientId, agentId); + } + + async buildSnapshotForUser(userInfo: SocketUserInfo): Promise { + const userId = resolveUserId(userInfo); + + if (!userId) { + return { + generatedAt: new Date().toISOString(), + environments: [], + clients: [], + spacesHasAttention: false, + }; + } + + const clientIds = await this.clientsService.getAccessibleClientIds( + userInfo.userId ?? userId, + userInfo.userRole, + userInfo.isApiKeyAuth, + ); + const environments = await this.buildEnvironmentsForUser(userId, clientIds); + const clients = rollupClients(environments); + + return { + generatedAt: new Date().toISOString(), + environments, + clients, + spacesHasAttention: spacesHasAttention(clients), + }; + } + + async emitSnapshotToSocket(socketId: string, userInfo: SocketUserInfo): Promise { + const snapshot = await this.buildSnapshotForUser(userInfo); + + this.lastSnapshotBySocketId.set(socketId, snapshot); + this.realtime.emitToUser(resolveUserId(userInfo) ?? '', 'statusSnapshot', snapshot); + + return snapshot; + } + + async runPollForSocket(socketId: string, userInfo: SocketUserInfo): Promise { + const userId = resolveUserId(userInfo); + + if (!userId) { + return; + } + + const previous = this.lastSnapshotBySocketId.get(socketId); + const next = await this.buildSnapshotForUser(userInfo); + + this.lastSnapshotBySocketId.set(socketId, next); + + const patch = this.diffSnapshots(previous, next); + + if (patch) { + this.realtime.emitToUser(userId, 'statusPatch', patch); + } + } + + async onAgentChatActivity(clientId: string, agentId: string, activityAt = new Date()): Promise { + await this.notifyEnvironmentActivity(clientId, agentId, activityAt); + } + + async onAutomationChatActivity(clientId: string, agentId: string, activityAt = new Date()): Promise { + await this.notifyEnvironmentActivity(clientId, agentId, activityAt); + } + + /** + * Pushes a status patch after git state changes (VCS mutations). + * Notifies every user with access to the workspace, same as chat activity hooks. + */ + async notifyVcsStateChanged(clientId: string, agentId: string): Promise { + const userIds = await this.resolveUserIdsToNotify(clientId); + + await Promise.all(userIds.map((userId) => this.pushPatchForUser(userId, clientId, agentId))); + } + + private async notifyEnvironmentActivity(clientId: string, agentId: string, activityAt: Date): Promise { + const userIds = await this.resolveUserIdsToNotify(clientId); + + for (const userId of userIds) { + const readState = await this.readStateRepository.findOne(userId, clientId, agentId); + const activeForUser = this.findActiveEnvironmentForUser(userId, clientId, agentId); + + if (activeForUser) { + await this.readStateRepository.upsertReadState({ + userId, + clientId, + agentId, + lastReadAt: activityAt, + lastReadAgentMessageId: readState?.lastReadAgentMessageId ?? null, + }); + } + + await this.pushPatchForUser(userId, clientId, agentId); + } + } + + isActiveEnvironmentForSocket(socketId: string, clientId: string, agentId: string): boolean { + const active = this.activeEnvironmentBySocketId.get(socketId); + + return active?.clientId === clientId && active?.agentId === agentId; + } + + private findActiveEnvironmentForUser(userId: string, clientId: string, agentId: string): boolean { + for (const [socketId, active] of this.activeEnvironmentBySocketId.entries()) { + if (!active || active.clientId !== clientId || active.agentId !== agentId) { + continue; + } + + if (this.realtime.getUserIdForSocket(socketId) === userId) { + return true; + } + } + + return false; + } + + private async pushPatchForUser(userId: string, clientId: string, agentId: string): Promise { + const env = await this.buildEnvironmentStatus(userId, clientId, agentId); + + if (!env) { + return; + } + + const patch: StatusPatchPayload = { + generatedAt: new Date().toISOString(), + environments: [env], + }; + + this.realtime.emitToUser(userId, 'statusPatch', patch); + } + + private async buildEnvironmentsForUser(userId: string, clientIds: string[]): Promise { + const readStates = await this.readStateRepository.findByUserAndClientIds(userId, clientIds); + const readByKey = new Map(); + + for (const row of readStates) { + readByKey.set(`${row.clientId}:${row.agentId}`, row); + } + + const environments: EnvironmentStatusPayload[] = []; + const vcsConcurrency = this.getVcsConcurrency(); + + for (const clientId of clientIds) { + let agents: { id: string }[] = []; + + try { + agents = await this.agentProxy.getClientAgents(clientId, 1000, 0); + } catch (error) { + this.logger.debug(`Skipping agents for client ${clientId}: ${(error as Error).message}`); + + continue; + } + + const automationByAgent = await this.automationRunsStatusRepository.findLatestUpdatedAtByClient(clientId); + + for (let i = 0; i < agents.length; i += vcsConcurrency) { + const chunk = agents.slice(i, i + vcsConcurrency); + const chunkResults = await Promise.all( + chunk.map(async (agent) => { + const readState = readByKey.get(`${clientId}:${agent.id}`); + const latestAgentMsg = await this.messagesProxy.getLatestAgentMessage(clientId, agent.id); + const latestAutomationAt = automationByAgent.get(agent.id) ?? null; + const latestAgentAt = latestAgentMsg ? new Date(latestAgentMsg.createdAt) : null; + const latestActivityAt = this.maxDate(latestAgentAt, latestAutomationAt); + const lastReadAt = readState?.lastReadAt ?? null; + const hasUnreadMessages = + latestActivityAt !== null && (lastReadAt === null || latestActivityAt > lastReadAt); + let gitDirty = false; + let gitConflict = false; + + try { + const gitStatus = await this.vcsProxy.getStatus(clientId, agent.id); + const mapped = mapGitStatus(gitStatus); + + gitDirty = mapped.gitDirty; + gitConflict = mapped.gitConflict; + } catch { + // Agent offline or VCS unavailable + } + + return { + clientId, + agentId: agent.id, + hasUnreadMessages, + gitDirty, + gitConflict, + } satisfies EnvironmentStatusPayload; + }), + ); + + environments.push(...chunkResults); + } + } + + return environments; + } + + private async buildEnvironmentStatus( + userId: string, + clientId: string, + agentId: string, + ): Promise { + const rows = await this.buildEnvironmentsForUser(userId, [clientId]); + + return rows.find((r) => r.agentId === agentId) ?? null; + } + + private diffSnapshots( + previous: StatusSnapshotPayload | undefined, + next: StatusSnapshotPayload, + ): StatusPatchPayload | null { + if (!previous) { + return { + generatedAt: next.generatedAt, + environments: next.environments, + clients: next.clients, + spacesHasAttention: next.spacesHasAttention, + }; + } + + const prevEnvMap = new Map(previous.environments.map((e) => [`${e.clientId}:${e.agentId}`, e])); + const changedEnvs: EnvironmentStatusPayload[] = []; + + for (const env of next.environments) { + const key = `${env.clientId}:${env.agentId}`; + const prev = prevEnvMap.get(key); + + if ( + !prev || + prev.hasUnreadMessages !== env.hasUnreadMessages || + prev.gitDirty !== env.gitDirty || + prev.gitConflict !== env.gitConflict + ) { + changedEnvs.push(env); + } + } + + const prevClients = new Map(previous.clients.map((c) => [c.clientId, c])); + const changedClients: ClientStatusPayload[] = []; + + for (const client of next.clients) { + const prev = prevClients.get(client.clientId); + + if (!prev || prev.hasUnreadMessages !== client.hasUnreadMessages || prev.gitDirty !== client.gitDirty) { + changedClients.push(client); + } + } + + const attentionChanged = previous.spacesHasAttention !== next.spacesHasAttention; + + if (changedEnvs.length === 0 && changedClients.length === 0 && !attentionChanged) { + return null; + } + + return { + generatedAt: next.generatedAt, + environments: changedEnvs.length ? changedEnvs : undefined, + clients: changedClients.length ? changedClients : undefined, + spacesHasAttention: attentionChanged ? next.spacesHasAttention : undefined, + }; + } + + private async getUserIdsWithClientAccess(clientId: string): Promise { + const client = await this.clientsRepository.findById(clientId); + const userIds = new Set(); + + if (client?.userId) { + userIds.add(client.userId); + } + + const members = await this.clientUsersRepository.findByClientId(clientId); + + for (const member of members) { + userIds.add(member.userId); + } + + if (userIds.size === 0 && process.env.STATIC_API_KEY) { + userIds.add('api-key-user'); + } + + return [...userIds]; + } + + /** + * Workspace owner, client_users members, and any connected status user who has access + * (e.g. global admins) — aligned with ensureClientAccess / chat visibility. + */ + private async resolveUserIdsToNotify(clientId: string): Promise { + const userIds = new Set(await this.getUserIdsWithClientAccess(clientId)); + + for (const connectedUserId of this.realtime.getConnectedUserIds()) { + if (userIds.has(connectedUserId)) { + continue; + } + + const userRole = this.realtime.getUserRole(connectedUserId) ?? UserRole.USER; + const { hasAccess } = await checkClientAccess( + this.clientsRepository, + this.clientUsersRepository, + clientId, + connectedUserId, + userRole, + false, + ); + + if (hasAccess) { + userIds.add(connectedUserId); + } + } + + return [...userIds]; + } + + private maxDate(a: Date | null, b: Date | null): Date | null { + if (!a) { + return b; + } + + if (!b) { + return a; + } + + return a > b ? a : b; + } + + private getVcsConcurrency(): number { + const raw = parseInt(process.env.STATUS_VCS_CONCURRENCY || String(DEFAULT_VCS_CONCURRENCY), 10); + + return Number.isNaN(raw) || raw < 1 ? DEFAULT_VCS_CONCURRENCY : Math.min(raw, 10); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.spec.ts new file mode 100644 index 00000000..177bf210 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.spec.ts @@ -0,0 +1,91 @@ +import { LatestAgentMessageDto } from '@forepath/framework/backend/feature-agent-manager'; +import { AuthenticationType, ClientEntity } from '@forepath/identity/backend'; +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import axios from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; + +import { ClientAgentMessagesProxyService } from './client-agent-messages-proxy.service'; +import { ClientsService } from './clients.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('ClientAgentMessagesProxyService', () => { + let service: ClientAgentMessagesProxyService; + const mockClientId = 'client-uuid'; + const mockAgentId = 'agent-uuid'; + const mockClientEntity: ClientEntity = { + id: mockClientId, + name: 'Test', + description: '', + endpoint: 'https://example.com', + authenticationType: AuthenticationType.API_KEY, + apiKey: 'test-key', + createdAt: new Date(), + updatedAt: new Date(), + }; + const mockClientsService = { getAccessToken: jest.fn() }; + const mockClientsRepository = { findByIdOrThrow: jest.fn() }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientAgentMessagesProxyService, + { provide: ClientsService, useValue: mockClientsService }, + { provide: ClientsRepository, useValue: mockClientsRepository }, + ], + }).compile(); + + service = module.get(ClientAgentMessagesProxyService); + jest.clearAllMocks(); + mockClientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + }); + + describe('getLatestAgentMessage', () => { + it('proxies GET latest-agent with API key auth', async () => { + const dto: LatestAgentMessageDto = { + id: 'msg-1', + createdAt: '2026-01-01T00:00:00.000Z', + }; + + mockedAxios.request.mockResolvedValue({ status: 200, data: dto } as never); + + const result = await service.getLatestAgentMessage(mockClientId, mockAgentId); + + expect(result).toEqual(dto); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: `https://example.com/api/agents/${mockAgentId}/messages/latest-agent`, + headers: expect.objectContaining({ Authorization: 'Bearer test-key' }), + }), + ); + }); + + it('returns null on 404', async () => { + mockedAxios.request.mockResolvedValue({ status: 404, data: {} } as never); + + const result = await service.getLatestAgentMessage(mockClientId, mockAgentId); + + expect(result).toBeNull(); + }); + + it('throws BadRequestException on other 4xx', async () => { + mockedAxios.request.mockResolvedValue({ status: 400, data: { message: 'bad' } } as never); + + await expect(service.getLatestAgentMessage(mockClientId, mockAgentId)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('returns null on network failure', async () => { + mockedAxios.request.mockRejectedValue({ message: 'timeout', response: undefined }); + + const result = await service.getLatestAgentMessage(mockClientId, mockAgentId); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.ts new file mode 100644 index 00000000..0cae02eb --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-messages-proxy.service.ts @@ -0,0 +1,107 @@ +import { LatestAgentMessageDto } from '@forepath/framework/backend/feature-agent-manager'; +import { AuthenticationType } from '@forepath/identity/backend'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; +import { buildClientProxyRequestHeaders } from '../utils/client-proxy-request-headers'; + +import { ClientsService } from './clients.service'; + +@Injectable() +export class ClientAgentMessagesProxyService { + private readonly logger = new Logger(ClientAgentMessagesProxyService.name); + + constructor( + private readonly clientsService: ClientsService, + private readonly clientsRepository: ClientsRepository, + ) {} + + private async getAuthHeader(clientId: string): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + + if (clientEntity.authenticationType === AuthenticationType.API_KEY) { + if (!clientEntity.apiKey) { + throw new BadRequestException('API key is not configured for this client'); + } + + return `Bearer ${clientEntity.apiKey}`; + } + + if (clientEntity.authenticationType === AuthenticationType.KEYCLOAK) { + const token = await this.clientsService.getAccessToken(clientId); + + return `Bearer ${token}`; + } + + throw new BadRequestException(`Unsupported authentication type: ${clientEntity.authenticationType}`); + } + + private buildMessagesUrl(endpoint: string, agentId: string): string { + const baseUrl = endpoint.replace(/\/$/, ''); + + return `${baseUrl}/api/agents/${agentId}/messages/latest-agent`; + } + + private async makeRequest(clientId: string, agentId: string, config: AxiosRequestConfig): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + + await validateClientEndpointWithDnsOrThrow(clientEntity.endpoint); + const authHeader = await this.getAuthHeader(clientId); + const url = this.buildMessagesUrl(clientEntity.endpoint, agentId); + const tlsPolicy = getClientEndpointTlsPolicy(this.logger); + + try { + const response = await axios.request({ + ...config, + url, + headers: buildClientProxyRequestHeaders(config.headers, authHeader), + validateStatus: (status) => status < 500, + timeout: process.env.REQUEST_TIMEOUT ? parseInt(process.env.REQUEST_TIMEOUT, 10) : 60000, + httpsAgent: url.startsWith('https://') + ? // eslint-disable-next-line @typescript-eslint/no-var-requires + new (require('https').Agent)({ + rejectUnauthorized: tlsPolicy.rejectUnauthorized, + }) + : undefined, + }); + + if (response.status === 404) { + return null; + } + + if (response.status >= 400) { + const errorMessage = (response.data as { message?: string })?.message || 'Request failed'; + + if (response.status === 404) { + return null; + } + + throw new BadRequestException(errorMessage); + } + + return response.data; + } catch (error) { + if (error instanceof BadRequestException || error instanceof NotFoundException) { + throw error; + } + + const axiosError = error as AxiosError; + + if (axiosError.response?.status === 404) { + return null; + } + + this.logger.debug( + `Latest agent message request failed for client ${clientId}, agent ${agentId}: ${axiosError.message}`, + ); + + return null; + } + } + + async getLatestAgentMessage(clientId: string, agentId: string): Promise { + return await this.makeRequest(clientId, agentId, { method: 'GET' }); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts index 26ea7711..a250b25f 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.spec.ts @@ -12,6 +12,7 @@ import axios, { AxiosError } from 'axios'; import { ClientsRepository } from '../repositories/clients.repository'; +import { AgentConsoleStatusService } from './agent-console-status.service'; import { ClientAgentVcsProxyService } from './client-agent-vcs-proxy.service'; import { ClientsService } from './clients.service'; @@ -40,6 +41,9 @@ describe('ClientAgentVcsProxyService', () => { const mockClientsRepository = { findByIdOrThrow: jest.fn(), }; + const mockAgentConsoleStatusService = { + notifyVcsStateChanged: jest.fn().mockResolvedValue(undefined), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -47,6 +51,7 @@ describe('ClientAgentVcsProxyService', () => { ClientAgentVcsProxyService, { provide: ClientsService, useValue: mockClientsService }, { provide: ClientsRepository, useValue: mockClientsRepository }, + { provide: AgentConsoleStatusService, useValue: mockAgentConsoleStatusService }, ], }).compile(); @@ -196,6 +201,49 @@ describe('ClientAgentVcsProxyService', () => { url: `https://example.com/api/agents/${mockAgentId}/vcs/branches/${encodeURIComponent('feature/foo bar')}/switch`, }), ); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith(mockClientId, mockAgentId); + }); + }); + + describe('git state notifications', () => { + it('should notify status socket after successful push', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.push(mockClientId, mockAgentId, { force: true }); + + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith(mockClientId, mockAgentId); + }); + + it('should notify status socket after successful fetch', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.fetch(mockClientId, mockAgentId); + + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith(mockClientId, mockAgentId); + }); + + it('should not notify status socket when getStatus fails', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 404, + data: { message: 'Agent not found' }, + } as any); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(NotFoundException); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).not.toHaveBeenCalled(); + }); + + it('should not notify status socket when push fails', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 400, + data: { message: 'Push rejected' }, + } as any); + + await expect(service.push(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).not.toHaveBeenCalled(); }); }); @@ -215,6 +263,82 @@ describe('ClientAgentVcsProxyService', () => { data: body, }), ); + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledWith(mockClientId, mockAgentId); + }); + }); + + describe('additional git mutations', () => { + beforeEach(() => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + }); + + it('notifies after pull and commit', async () => { + await service.pull(mockClientId, mockAgentId); + await service.commit(mockClientId, mockAgentId, { message: 'feat: test' }); + + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledTimes(2); + }); + + it('notifies after stage and unstage', async () => { + await service.stageFiles(mockClientId, mockAgentId, { files: ['a.ts'] }); + await service.unstageFiles(mockClientId, mockAgentId, { files: ['a.ts'] }); + + expect(mockAgentConsoleStatusService.notifyVcsStateChanged).toHaveBeenCalledTimes(2); + }); + }); + + describe('auth and error handling', () => { + it('throws for unsupported authentication type', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue({ + ...mockClientEntity, + authenticationType: 'unknown' as AuthenticationType, + }); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + + it('maps non-404/400 HTTP status to BadRequestException', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 403, + data: { message: 'Forbidden' }, + } as any); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow('Request failed: Forbidden'); + }); + + it('maps axios response errors with non-400 status', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + const err = new AxiosError('Forbidden'); + + err.response = { status: 403, data: { message: 'denied' } } as never; + mockedAxios.request.mockRejectedValue(err); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow('Request failed: denied'); + }); + + it('maps axios setup errors', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + const err = new AxiosError('Invalid config'); + + mockedAxios.request.mockRejectedValue(err); + + await expect(service.getStatus(mockClientId, mockAgentId)).rejects.toThrow('Request setup failed'); + }); + + it('logs when status notification fails after mutation', async () => { + const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined); + + mockAgentConsoleStatusService.notifyVcsStateChanged.mockRejectedValue(new Error('socket down')); + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ status: 204, data: undefined } as any); + + await service.fetch(mockClientId, mockAgentId); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to publish status patch')); + warnSpy.mockRestore(); }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts index 7cdeb402..29a15dd2 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-vcs-proxy.service.ts @@ -14,13 +14,14 @@ import { UnstageFilesDto, } from '@forepath/framework/backend/feature-agent-manager'; import { AuthenticationType } from '@forepath/identity/backend'; -import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, forwardRef, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import axios, { AxiosError, AxiosRequestConfig } from 'axios'; import { ClientsRepository } from '../repositories/clients.repository'; import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; import { buildClientProxyRequestHeaders } from '../utils/client-proxy-request-headers'; +import { AgentConsoleStatusService } from './agent-console-status.service'; import { ClientsService } from './clients.service'; /** @@ -34,8 +35,26 @@ export class ClientAgentVcsProxyService { constructor( private readonly clientsService: ClientsService, private readonly clientsRepository: ClientsRepository, + @Inject(forwardRef(() => AgentConsoleStatusService)) + private readonly agentConsoleStatusService: AgentConsoleStatusService, ) {} + private emitGitStateChanged(clientId: string, agentId: string): void { + void this.agentConsoleStatusService.notifyVcsStateChanged(clientId, agentId).catch((error: unknown) => { + this.logger.warn( + `Failed to publish status patch after VCS change for ${clientId}/${agentId}: ${(error as Error).message}`, + ); + }); + } + + private async runGitStateMutation(clientId: string, agentId: string, operation: () => Promise): Promise { + const result = await operation(); + + this.emitGitStateChanged(clientId, agentId); + + return result; + } + /** * Get authentication header for a client. * @param clientId - The UUID of the client @@ -208,11 +227,13 @@ export class ClientAgentVcsProxyService { * @param stageFilesDto - Files to stage (empty array stages all) */ async stageFiles(clientId: string, agentId: string, stageFilesDto: StageFilesDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/stage', - data: stageFilesDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/stage', + data: stageFilesDto, + }), + ); } /** @@ -222,11 +243,13 @@ export class ClientAgentVcsProxyService { * @param unstageFilesDto - Files to unstage (empty array unstages all) */ async unstageFiles(clientId: string, agentId: string, unstageFilesDto: UnstageFilesDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/unstage', - data: unstageFilesDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/unstage', + data: unstageFilesDto, + }), + ); } /** @@ -236,11 +259,13 @@ export class ClientAgentVcsProxyService { * @param commitDto - Commit message */ async commit(clientId: string, agentId: string, commitDto: CommitDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/commit', - data: commitDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/commit', + data: commitDto, + }), + ); } /** @@ -250,11 +275,13 @@ export class ClientAgentVcsProxyService { * @param pushOptions - Optional push options (e.g., force flag) */ async push(clientId: string, agentId: string, pushOptions: { force?: boolean } = {}): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/push', - data: pushOptions, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/push', + data: pushOptions, + }), + ); } /** @@ -263,10 +290,12 @@ export class ClientAgentVcsProxyService { * @param agentId - The UUID of the agent */ async pull(clientId: string, agentId: string): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/pull', - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/pull', + }), + ); } /** @@ -275,10 +304,12 @@ export class ClientAgentVcsProxyService { * @param agentId - The UUID of the agent */ async fetch(clientId: string, agentId: string): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/fetch', - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/fetch', + }), + ); } /** @@ -288,11 +319,13 @@ export class ClientAgentVcsProxyService { * @param rebaseDto - Branch to rebase onto */ async rebase(clientId: string, agentId: string, rebaseDto: RebaseDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/rebase', - data: rebaseDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/rebase', + data: rebaseDto, + }), + ); } /** @@ -302,10 +335,12 @@ export class ClientAgentVcsProxyService { * @param branch - Branch name to switch to */ async switchBranch(clientId: string, agentId: string, branch: string): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: `/branches/${encodeURIComponent(branch)}/switch`, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: `/branches/${encodeURIComponent(branch)}/switch`, + }), + ); } /** @@ -315,11 +350,13 @@ export class ClientAgentVcsProxyService { * @param createBranchDto - Branch creation data */ async createBranch(clientId: string, agentId: string, createBranchDto: CreateBranchDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/branches', - data: createBranchDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/branches', + data: createBranchDto, + }), + ); } /** @@ -329,10 +366,12 @@ export class ClientAgentVcsProxyService { * @param branch - Branch name to delete */ async deleteBranch(clientId: string, agentId: string, branch: string): Promise { - await this.makeRequest(clientId, agentId, { - method: 'DELETE', - url: `/branches/${encodeURIComponent(branch)}`, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'DELETE', + url: `/branches/${encodeURIComponent(branch)}`, + }), + ); } /** @@ -342,22 +381,26 @@ export class ClientAgentVcsProxyService { * @param resolveConflictDto - Conflict resolution data */ async resolveConflict(clientId: string, agentId: string, resolveConflictDto: ResolveConflictDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/conflicts/resolve', - data: resolveConflictDto, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/conflicts/resolve', + data: resolveConflictDto, + }), + ); } /** * Fetch, checkout base branch, hard reset to upstream, and clean (proxied to client agent-manager). */ async prepareCleanWorkspace(clientId: string, agentId: string, body: PrepareCleanWorkspaceDto): Promise { - await this.makeRequest(clientId, agentId, { - method: 'POST', - url: '/workspace/prepare-clean', - data: body, - }); + await this.runGitStateMutation(clientId, agentId, () => + this.makeRequest(clientId, agentId, { + method: 'POST', + url: '/workspace/prepare-clean', + data: body, + }), + ); } /** diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts index 995b6ed3..55fc2f4b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.spec.ts @@ -7,6 +7,7 @@ import { TicketAutomationRunPhase, TicketAutomationRunStatus } from '../entities import { TicketEntity } from '../entities/ticket.entity'; import { TicketPriority, TicketStatus } from '../entities/ticket.enums'; +import { AgentConsoleStatusService } from './agent-console-status.service'; import { ClientAutomationChatRealtimeService } from './client-automation-chat-realtime.service'; import { TicketAutomationChatSyncService } from './ticket-automation-chat-sync.service'; @@ -19,6 +20,9 @@ describe('TicketAutomationChatSyncService', () => { const ticketRepo = { findOne: jest.fn() }; const automationRepo = { findOne: jest.fn() }; const chatRealtime = { emitToClient: jest.fn(), emitToSocket: jest.fn() }; + const agentConsoleStatusService = { + onAutomationChatActivity: jest.fn().mockResolvedValue(undefined), + }; beforeEach(async () => { jest.clearAllMocks(); @@ -38,6 +42,7 @@ describe('TicketAutomationChatSyncService', () => { { provide: getRepositoryToken(TicketEntity), useValue: ticketRepo }, { provide: getRepositoryToken(TicketAutomationEntity), useValue: automationRepo }, { provide: ClientAutomationChatRealtimeService, useValue: chatRealtime }, + { provide: AgentConsoleStatusService, useValue: agentConsoleStatusService }, ], }).compile(); @@ -90,5 +95,48 @@ describe('TicketAutomationChatSyncService', () => { expect(payload.hydrate).toBe(true); expect(payload.run.id).toBe('r1'); + expect(agentConsoleStatusService.onAutomationChatActivity).not.toHaveBeenCalled(); + }); + + it('emitLiveRunUpdateFromEntity notifies status service', async () => { + const run = { + id: 'r1', + ticketId: 't1', + clientId: 'c1', + agentId: 'a1', + status: TicketAutomationRunStatus.SUCCEEDED, + phase: TicketAutomationRunPhase.FINALIZE, + ticketStatusBefore: TicketStatus.TODO, + startedAt: new Date('2020-01-01'), + updatedAt: new Date('2020-01-02'), + finishedAt: new Date('2020-01-02'), + iterationCount: 1, + completionMarkerSeen: true, + verificationPassed: true, + failureCode: null, + summary: null, + cancelRequestedAt: null, + cancelledByUserId: null, + cancellationReason: null, + } as TicketAutomationRunEntity; + + ticketRepo.findOne.mockResolvedValue({ + id: 't1', + clientId: 'c1', + title: 'Hello', + priority: TicketPriority.MEDIUM, + status: TicketStatus.TODO, + preferredChatAgentId: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + automationRepo.findOne.mockResolvedValue({ eligible: true }); + + service.emitLiveRunUpdateFromEntity(run); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(chatRealtime.emitToClient).toHaveBeenCalled(); + expect(agentConsoleStatusService.onAutomationChatActivity).toHaveBeenCalledWith('c1', 'a1', run.updatedAt); }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts index 44b26b03..98d38095 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts @@ -12,6 +12,7 @@ import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; import { TicketEntity } from '../entities/ticket.entity'; import { ticketAutomationRunEntityToDto } from '../utils/ticket-board-realtime-mappers'; +import { AgentConsoleStatusService } from './agent-console-status.service'; import { ClientAutomationChatRealtimeService } from './client-automation-chat-realtime.service'; /** Cap for post-login hydration (per agent + client). */ @@ -34,6 +35,7 @@ export class TicketAutomationChatSyncService { @InjectRepository(TicketAutomationEntity) private readonly automationRepo: Repository, private readonly clientAutomationChatRealtime: ClientAutomationChatRealtimeService, + private readonly agentConsoleStatusService: AgentConsoleStatusService, ) {} /** @@ -73,6 +75,9 @@ export class TicketAutomationChatSyncService { if (payload) { this.clientAutomationChatRealtime.emitToClient(run.clientId, payload); + void this.agentConsoleStatusService + .onAutomationChatActivity(run.clientId, run.agentId, run.updatedAt) + .catch(() => undefined); } } @@ -81,6 +86,9 @@ export class TicketAutomationChatSyncService { void this.buildPayload(run, false).then((payload) => { if (payload) { this.clientAutomationChatRealtime.emitToClient(run.clientId, payload); + void this.agentConsoleStatusService + .onAutomationChatActivity(run.clientId, run.agentId, run.updatedAt) + .catch(() => undefined); } }); } 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 c2b36bd8..e05d6d14 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/index.ts @@ -16,6 +16,7 @@ export * from './lib/dto/file-node.dto'; export * from './lib/dto/git-branch.dto'; export * from './lib/dto/git-diff.dto'; export * from './lib/dto/git-status.dto'; +export * from './lib/dto/latest-agent-message.dto'; export * from './lib/dto/move-file.dto'; export * from './lib/dto/prepare-clean-workspace.dto'; export * from './lib/dto/push-options.dto'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.spec.ts new file mode 100644 index 00000000..517840c4 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.spec.ts @@ -0,0 +1,14 @@ +import { toolMayMutateGitWorkspace } from './agent-git-state.constants'; + +describe('toolMayMutateGitWorkspace', () => { + it('returns true for workspace-mutating tool names', () => { + expect(toolMayMutateGitWorkspace('write_file')).toBe(true); + expect(toolMayMutateGitWorkspace('bash')).toBe(true); + expect(toolMayMutateGitWorkspace('git-commit')).toBe(true); + }); + + it('returns false for read-only tool names', () => { + expect(toolMayMutateGitWorkspace('read')).toBe(false); + expect(toolMayMutateGitWorkspace('grep')).toBe(false); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.ts new file mode 100644 index 00000000..40c14927 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/constants/agent-git-state.constants.ts @@ -0,0 +1,9 @@ +/** Emitted on the agents WebSocket namespace when workspace git state may have changed. */ +export const GIT_STATE_CHANGED_EVENT = 'gitStateChanged'; + +const GIT_AFFECTING_TOOL_NAME = + /write|edit|patch|apply|bash|shell|terminal|run|git|commit|mkdir|delete|remove|move|create/i; + +export function toolMayMutateGitWorkspace(toolName: string): boolean { + return GIT_AFFECTING_TOOL_NAME.test(toolName.trim()); +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.spec.ts new file mode 100644 index 00000000..eec8c201 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.spec.ts @@ -0,0 +1,44 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { AgentMessagesService } from '../services/agent-messages.service'; + +import { AgentsMessagesController } from './agents-messages.controller'; + +describe('AgentsMessagesController', () => { + let controller: AgentsMessagesController; + const agentMessagesService = { + getLatestAgentMessage: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AgentsMessagesController], + providers: [{ provide: AgentMessagesService, useValue: agentMessagesService }], + }).compile(); + + controller = module.get(AgentsMessagesController); + jest.clearAllMocks(); + }); + + it('returns latest agent message', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + + agentMessagesService.getLatestAgentMessage.mockResolvedValue({ + id: 'msg-1', + createdAt, + }); + + const result = await controller.getLatestAgentMessage('00000000-0000-4000-8000-000000000001'); + + expect(result).toEqual({ id: 'msg-1', createdAt: createdAt.toISOString() }); + }); + + it('throws when no agent messages exist', async () => { + agentMessagesService.getLatestAgentMessage.mockResolvedValue(null); + + await expect(controller.getLatestAgentMessage('00000000-0000-4000-8000-000000000001')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts new file mode 100644 index 00000000..8cc4ca4a --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, NotFoundException, Param, ParseUUIDPipe } from '@nestjs/common'; + +import { LatestAgentMessageDto } from '../dto/latest-agent-message.dto'; +import { AgentMessagesService } from '../services/agent-messages.service'; + +/** + * HTTP endpoints for agent message metadata (not full chat history). + */ +@Controller('agents') +export class AgentsMessagesController { + constructor(private readonly agentMessagesService: AgentMessagesService) {} + + @Get(':id/messages/latest-agent') + async getLatestAgentMessage( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + const latest = await this.agentMessagesService.getLatestAgentMessage(id); + + if (!latest) { + throw new NotFoundException('No agent messages found for this environment'); + } + + return { + id: latest.id, + createdAt: latest.createdAt.toISOString(), + }; + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/latest-agent-message.dto.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/latest-agent-message.dto.ts new file mode 100644 index 00000000..a109c5ba --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/dto/latest-agent-message.dto.ts @@ -0,0 +1,7 @@ +/** + * Latest persisted agent chat message metadata for unread tracking. + */ +export class LatestAgentMessageDto { + id!: string; + createdAt!: string; +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index bc35a4e3..3dca976e 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -8,6 +8,7 @@ import { AgentProvider, AgentResponseObject } from '../providers/agent-provider. import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { ChatFilter, FilterDirection } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; @@ -117,8 +118,18 @@ describe('AgentsGateway', () => { const mockAgentSessionHydrationService = { consumePendingSummary: jest.fn().mockReturnValue(undefined), }; + let gitStateBroadcaster: ((agentId: string) => void) | undefined; + const mockGitStateBroadcast = { + registerBroadcaster: jest.fn((broadcaster: (agentId: string) => void) => { + gitStateBroadcaster = broadcaster; + }), + notifyGitStateMayHaveChanged: jest.fn((agentId: string) => { + gitStateBroadcaster?.(agentId); + }), + }; beforeEach(async () => { + gitStateBroadcaster = undefined; const module: TestingModule = await Test.createTestingModule({ providers: [ AgentsGateway, @@ -158,10 +169,15 @@ describe('AgentsGateway', () => { provide: AgentSessionHydrationService, useValue: mockAgentSessionHydrationService, }, + { + provide: AgentGitStateBroadcastService, + useValue: mockGitStateBroadcast, + }, ], }).compile(); gateway = module.get(AgentsGateway); + gateway.onModuleInit(); agentsService = module.get(AgentsService); agentsRepository = module.get(AgentsRepository); dockerService = module.get(DockerService); @@ -2665,6 +2681,41 @@ describe('AgentsGateway', () => { }); }); + describe('git state changed broadcasts', () => { + it('emits gitStateChanged after workspace tool results', () => { + const socketId = mockSocket.id || 'test-socket-id'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(socketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(socketId, mockSocket); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).broadcastChatEvent(mockAgent.id, { + eventId: 'event-1', + kind: 'toolResult', + agentId: mockAgent.id, + correlationId: 'corr-1', + sequence: 2, + timestamp: new Date().toISOString(), + payload: { + toolCallId: 'tool-1', + name: 'write_file', + result: { ok: true }, + isError: false, + }, + } as AgentEventEnvelope); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'gitStateChanged', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ agentId: mockAgent.id }), + }), + ); + }); + }); + describe('handleFileUpdate', () => { it('should broadcast file update notification for authenticated user', async () => { const socketId = mockSocket.id || 'test-socket-id'; @@ -2697,6 +2748,16 @@ describe('AgentsGateway', () => { timestamp: expect.any(String), }), ); + expect(mockSocket.emit).toHaveBeenCalledWith( + 'gitStateChanged', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + agentId: mockAgent.id, + timestamp: expect.any(String), + }), + }), + ); loggerLogSpy.mockRestore(); }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 463bdf8e..0c700630 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -1,4 +1,4 @@ -import { Logger } from '@nestjs/common'; +import { Logger, OnModuleInit } from '@nestjs/common'; import { ConnectedSocket, MessageBody, @@ -11,6 +11,7 @@ import { import { Server, Socket } from 'socket.io'; import { v4 as uuidv4 } from 'uuid'; +import { GIT_STATE_CHANGED_EVENT, toolMayMutateGitWorkspace } from '../constants/agent-git-state.constants'; import { AgentEventEnvelope, AgentInteractionQueryPayload, AgentResponseMode } from '../providers/agent-events.types'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentResponseObject } from '../providers/agent-provider.interface'; @@ -22,6 +23,7 @@ import { FilterDirection, } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; @@ -91,6 +93,11 @@ interface FileUpdatePayload { filePath: string; } +interface GitStateChangedData { + agentId: string; + timestamp: string; +} + interface CreateTerminalPayload { sessionId?: string; shell?: string; @@ -232,7 +239,7 @@ function toAgentEventEnvelopeBase( skipMiddlewares: true, }, }) -export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { +export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, OnModuleInit { @WebSocketServer() server: Server; @@ -264,8 +271,13 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly chatFilterFactory: ChatFilterFactory, private readonly promptContextComposer: PromptContextComposerService, private readonly agentSessionHydrationService: AgentSessionHydrationService, + private readonly gitStateBroadcast: AgentGitStateBroadcastService, ) {} + onModuleInit(): void { + this.gitStateBroadcast.registerBroadcaster((agentId) => this.broadcastGitStateChanged(agentId)); + } + /** * Handle client connection. * @param socket - The connected socket instance @@ -386,6 +398,21 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { private broadcastChatEvent(agentUuid: string, event: AgentEventEnvelope): void { this.broadcastToAgent(agentUuid, 'chatEvent', createSuccessResponse(event)); void this.agentMessageEventsService.persistEvent(agentUuid, event); + + if (event.kind === 'toolResult' && !event.payload.isError && toolMayMutateGitWorkspace(event.payload.name)) { + this.gitStateBroadcast.notifyGitStateMayHaveChanged(agentUuid); + } + } + + private broadcastGitStateChanged(agentUuid: string): void { + this.broadcastToAgent( + agentUuid, + GIT_STATE_CHANGED_EVENT, + createSuccessResponse({ + agentId: agentUuid, + timestamp: new Date().toISOString(), + }), + ); } /** @@ -2131,6 +2158,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect { timestamp: updateTimestamp, }), ); + this.gitStateBroadcast.notifyGitStateMayHaveChanged(agentUuid); } catch (error) { socket.emit('error', createErrorResponse('Error processing file update', 'FILE_UPDATE_ERROR')); const err = error as { message?: string; stack?: string }; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts index d241e2f1..9469c247 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts @@ -3,6 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AgentsDeploymentsController } from '../controllers/agents-deployments.controller'; +import { AgentsMessagesController } from '../controllers/agents-messages.controller'; import { AgentsController } from '../controllers/agents.controller'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; import { AgentMessageEventEntity } from '../entities/agent-message-event.entity'; @@ -117,6 +118,13 @@ describe('AgentsModule', () => { expect(controller).toBeInstanceOf(AgentsController); }); + it('should provide AgentsMessagesController', () => { + const controller = module.get(AgentsMessagesController); + + expect(controller).toBeDefined(); + expect(controller).toBeInstanceOf(AgentsMessagesController); + }); + it('should provide AgentsGateway', () => { const gateway = module.get(AgentsGateway); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 85fb2231..b84cfb73 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -6,6 +6,7 @@ import { AgentsDeploymentsController } from '../controllers/agents-deployments.c import { AgentsEnvironmentVariablesController } from '../controllers/agents-environment-variables.controller'; import { AgentsFilesController } from '../controllers/agents-files.controller'; import { AgentsFiltersController } from '../controllers/agents-filters.controller'; +import { AgentsMessagesController } from '../controllers/agents-messages.controller'; import { AgentsVcsController } from '../controllers/agents-vcs.controller'; import { AgentsVerificationController } from '../controllers/agents-verification.controller'; import { AgentsController } from '../controllers/agents.controller'; @@ -44,6 +45,7 @@ import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.r import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; import { AgentEnvironmentVariablesService } from '../services/agent-environment-variables.service'; import { AgentFileSystemService } from '../services/agent-file-system.service'; +import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; @@ -78,6 +80,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co ], controllers: [ AgentsController, + AgentsMessagesController, AgentsFilesController, AgentsVcsController, AgentsVerificationController, @@ -95,6 +98,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co AgentMessageEventsService, AgentSessionHydrationService, AgentEnvironmentVariablesService, + AgentGitStateBroadcastService, AgentFileSystemService, AgentsVcsService, AgentsVerificationService, diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts index 188a56d7..d02ffecb 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts @@ -218,6 +218,30 @@ describe('AgentMessagesRepository', () => { }); }); + describe('findLatestAgentMessage', () => { + it('returns latest agent-authored message', async () => { + const agentMessage = { ...mockMessage, actor: 'agent' }; + + mockTypeOrmRepository.findOne.mockResolvedValue(agentMessage); + + const result = await repository.findLatestAgentMessage('agent-uuid-123'); + + expect(result).toEqual(agentMessage); + expect(mockTypeOrmRepository.findOne).toHaveBeenCalledWith({ + where: { agentId: 'agent-uuid-123', actor: 'agent' }, + order: { createdAt: 'DESC' }, + }); + }); + + it('returns null when no agent messages exist', async () => { + mockTypeOrmRepository.findOne.mockResolvedValue(null); + + const result = await repository.findLatestAgentMessage('agent-uuid-123'); + + expect(result).toBeNull(); + }); + }); + describe('deleteByAgentId', () => { it('should delete all messages for agent', async () => { mockTypeOrmRepository.delete.mockResolvedValue({ affected: 3 }); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts index 063f46d1..8bc54c36 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts @@ -89,6 +89,16 @@ export class AgentMessagesRepository { return await this.repository.count({ where: { agentId } }); } + /** + * Find the most recent agent-authored message for an environment. + */ + async findLatestAgentMessage(agentId: string): Promise { + return await this.repository.findOne({ + where: { agentId, actor: 'agent' }, + order: { createdAt: 'DESC' }, + }); + } + /** * Create a new message. * @param dto - Data transfer object for creating a message 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 220b2606..b21bb585 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 @@ -10,6 +10,7 @@ import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentsRepository } from '../repositories/agents.repository'; import { AgentFileSystemService } from './agent-file-system.service'; +import { AgentGitStateBroadcastService } from './agent-git-state-broadcast.service'; import { AgentsService } from './agents.service'; import { DockerService } from './docker.service'; @@ -55,6 +56,9 @@ describe('AgentFileSystemService', () => { const mockAgentProviderFactory = { getProvider: jest.fn().mockReturnValue(mockProvider), }; + const mockGitStateBroadcast = { + notifyGitStateMayHaveChanged: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -76,6 +80,10 @@ describe('AgentFileSystemService', () => { provide: AgentProviderFactory, useValue: mockAgentProviderFactory, }, + { + provide: AgentGitStateBroadcastService, + useValue: mockGitStateBroadcast, + }, ], }).compile(); @@ -365,6 +373,7 @@ describe('AgentFileSystemService', () => { expect(agentsService.findOne).toHaveBeenCalledWith(mockAgentId); expect(dockerService.sendCommandToContainer).toHaveBeenCalled(); + expect(mockGitStateBroadcast.notifyGitStateMayHaveChanged).toHaveBeenCalledWith(mockAgentId); }); it('should write binary file content successfully', async () => { 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 1f582df4..4ada7048 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 @@ -11,6 +11,7 @@ import { AgentsRepository } from '../repositories/agents.repository'; import type { AgentFileManagerContext } from '../utils/agent-file-manager-context'; import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; +import { AgentGitStateBroadcastService } from './agent-git-state-broadcast.service'; import { AgentsService } from './agents.service'; import { DockerService } from './docker.service'; @@ -30,8 +31,13 @@ export class AgentFileSystemService { private readonly agentsRepository: AgentsRepository, private readonly dockerService: DockerService, private readonly agentProviderFactory: AgentProviderFactory, + private readonly gitStateBroadcast: AgentGitStateBroadcastService, ) {} + private notifyGitStateMayHaveChanged(agentId: string): void { + this.gitStateBroadcast.notifyGitStateMayHaveChanged(agentId); + } + /** * Sanitize and validate a file path to prevent directory traversal attacks. * @param path - The file path to sanitize @@ -354,6 +360,7 @@ export class AgentFileSystemService { ); this.logger.debug(`File written: ${filePath} for agent ${agentId} (encoding: ${encoding || 'utf-8'})`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -574,6 +581,10 @@ export class AgentFileSystemService { } this.logger.debug(`Created ${type}: ${filePath} for agent ${agentId}`); + + if (type === 'directory' || content === undefined) { + this.notifyGitStateMayHaveChanged(agentId); + } } catch (error: unknown) { const err = error as { message?: string }; @@ -617,6 +628,7 @@ export class AgentFileSystemService { ); this.logger.debug(`Deleted: ${filePath} for agent ${agentId}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -672,6 +684,7 @@ export class AgentFileSystemService { ); this.logger.debug(`Moved: ${sourcePath} to ${destinationPath} for agent ${agentId}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.spec.ts new file mode 100644 index 00000000..be121c1d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.spec.ts @@ -0,0 +1,34 @@ +import { AgentGitStateBroadcastService } from './agent-git-state-broadcast.service'; + +describe('AgentGitStateBroadcastService', () => { + let service: AgentGitStateBroadcastService; + + beforeEach(() => { + service = new AgentGitStateBroadcastService(); + }); + + it('invokes registered broadcaster with agent id', () => { + const broadcaster = jest.fn(); + + service.registerBroadcaster(broadcaster); + service.notifyGitStateMayHaveChanged('agent-1'); + + expect(broadcaster).toHaveBeenCalledWith('agent-1'); + }); + + it('no-ops when broadcaster is not registered', () => { + expect(() => service.notifyGitStateMayHaveChanged('agent-1')).not.toThrow(); + }); + + it('logs and swallows broadcaster errors', () => { + const warnSpy = jest.spyOn(service['logger'], 'warn').mockImplementation(() => undefined); + + service.registerBroadcaster(() => { + throw new Error('broadcast failed'); + }); + service.notifyGitStateMayHaveChanged('agent-1'); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('broadcast failed')); + warnSpy.mockRestore(); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.ts new file mode 100644 index 00000000..f824fcde --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-git-state-broadcast.service.ts @@ -0,0 +1,23 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class AgentGitStateBroadcastService { + private readonly logger = new Logger(AgentGitStateBroadcastService.name); + private broadcaster?: (agentId: string) => void; + + registerBroadcaster(broadcaster: (agentId: string) => void): void { + this.broadcaster = broadcaster; + } + + notifyGitStateMayHaveChanged(agentId: string): void { + if (!this.broadcaster) { + return; + } + + try { + this.broadcaster(agentId); + } catch (error: unknown) { + this.logger.warn(`Failed to broadcast git state change for agent ${agentId}: ${(error as Error).message}`); + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts index 390a2166..70a9396e 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts @@ -30,6 +30,7 @@ describe('AgentMessagesService', () => { const mockRepository = { create: jest.fn(), findByAgentId: jest.fn(), + findLatestAgentMessage: jest.fn(), countByAgentId: jest.fn(), deleteByAgentId: jest.fn(), }; @@ -295,6 +296,35 @@ describe('AgentMessagesService', () => { }); }); + describe('getLatestAgentMessage', () => { + it('returns id and createdAt when message exists', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + + mockRepository.findLatestAgentMessage.mockResolvedValue({ + id: 'msg-1', + createdAt, + agentId: 'agent-uuid-123', + actor: 'agent', + message: 'hi', + filtered: false, + updatedAt: createdAt, + }); + + const result = await service.getLatestAgentMessage('agent-uuid-123'); + + expect(result).toEqual({ id: 'msg-1', createdAt }); + expect(mockRepository.findLatestAgentMessage).toHaveBeenCalledWith('agent-uuid-123'); + }); + + it('returns null when repository has no agent message', async () => { + mockRepository.findLatestAgentMessage.mockResolvedValue(null); + + const result = await service.getLatestAgentMessage('agent-uuid-123'); + + expect(result).toBeNull(); + }); + }); + describe('deleteAllMessages', () => { it('should delete all messages for an agent', async () => { const agentId = 'agent-uuid-123'; diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts index c9e31f89..72112e63 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts @@ -91,6 +91,19 @@ export class AgentMessagesService { return await this.agentMessagesRepository.countByAgentId(agentId); } + /** + * Latest agent-authored message for unread cursor comparison. + */ + async getLatestAgentMessage(agentId: string): Promise<{ id: string; createdAt: Date } | null> { + const message = await this.agentMessagesRepository.findLatestAgentMessage(agentId); + + if (!message) { + return null; + } + + return { id: message.id, createdAt: message.createdAt }; + } + /** * Delete all messages for a specific agent. * @param agentId - The UUID of the agent diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts index 27b1c937..d3c5b0df 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts @@ -7,6 +7,7 @@ import { AgentEntity, ContainerType } from '../entities/agent.entity'; import { AgentsRepository } from '../repositories/agents.repository'; import { AgentFileSystemService } from './agent-file-system.service'; +import { AgentGitStateBroadcastService } from './agent-git-state-broadcast.service'; import { AgentsVcsService } from './agents-vcs.service'; import { AgentsService } from './agents.service'; import { DockerService } from './docker.service'; @@ -43,6 +44,9 @@ describe('AgentsVcsService', () => { const mockAgentFileSystemService = { readFile: jest.fn(), }; + const mockGitStateBroadcast = { + notifyGitStateMayHaveChanged: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -64,6 +68,10 @@ describe('AgentsVcsService', () => { provide: AgentFileSystemService, useValue: mockAgentFileSystemService, }, + { + provide: AgentGitStateBroadcastService, + useValue: mockGitStateBroadcast, + }, ], }).compile(); @@ -279,6 +287,7 @@ describe('AgentsVcsService', () => { undefined, false, ); + expect(mockGitStateBroadcast.notifyGitStateMayHaveChanged).toHaveBeenCalledWith(mockAgentId); }); it('should stage all files when empty array provided', async () => { diff --git a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts index 16370b82..ec604dd2 100644 --- a/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts +++ b/libs/domains/framework/backend/feature-agent-manager/src/lib/services/agents-vcs.service.ts @@ -8,6 +8,7 @@ import { ResolveConflictDto } from '../dto/resolve-conflict.dto'; import { AgentsRepository } from '../repositories/agents.repository'; import { AgentFileSystemService } from './agent-file-system.service'; +import { AgentGitStateBroadcastService } from './agent-git-state-broadcast.service'; import { AgentsService } from './agents.service'; import { DockerService } from './docker.service'; @@ -30,12 +31,17 @@ export class AgentsVcsService { private readonly agentsRepository: AgentsRepository, private readonly dockerService: DockerService, private readonly agentFileSystemService: AgentFileSystemService, + private readonly gitStateBroadcast: AgentGitStateBroadcastService, ) { // Get commit author from environment variables this.commitAuthorName = process.env.GIT_COMMIT_AUTHOR_NAME || 'Agenstra Agent'; this.commitAuthorEmail = process.env.GIT_COMMIT_AUTHOR_EMAIL || 'agent@agenstra.local'; } + private notifyGitStateMayHaveChanged(agentId: string): void { + this.gitStateBroadcast.notifyGitStateMayHaveChanged(agentId); + } + /** * Clean command output by removing control characters and null bytes. * Preserves leading/trailing whitespace unless explicitly trimmed. @@ -708,6 +714,8 @@ export class AgentsVcsService { await this.executeGitCommand(agentEntity.containerId, `add ${escapedFiles}`); } + + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -739,6 +747,8 @@ export class AgentsVcsService { await this.executeGitCommand(agentEntity.containerId, `reset HEAD ${escapedFiles}`); } + + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -772,6 +782,7 @@ export class AgentsVcsService { agentEntity.containerId, `-c user.name='${this.commitAuthorName}' -c user.email='${this.commitAuthorEmail}' commit -m '${escapedMessage}'`, ); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -818,6 +829,7 @@ export class AgentsVcsService { // This will cause Git to fail immediately if credentials aren't available // Also enable exit code checking to properly detect and report push failures await this.executeGitCommand(agentEntity.containerId, pushCommand, this.BASE_PATH, false, true, true); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -871,6 +883,7 @@ export class AgentsVcsService { true, true, ); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -911,6 +924,7 @@ export class AgentsVcsService { // Execute fetch with disablePrompts=true to prevent interactive credential prompts // Also enable exit code checking to properly detect and report fetch failures await this.executeGitCommand(agentEntity.containerId, 'fetch origin', this.BASE_PATH, false, true, true); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -952,6 +966,7 @@ export class AgentsVcsService { const cleanBranchName = this.cleanBranchName(branch); await this.executeGitCommand(agentEntity.containerId, `rebase ${this.escapePath(cleanBranchName)}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -977,6 +992,7 @@ export class AgentsVcsService { const cleanBranchName = this.cleanBranchName(branch); await this.executeGitCommand(agentEntity.containerId, `checkout ${this.escapePath(cleanBranchName)}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -1019,6 +1035,7 @@ export class AgentsVcsService { const baseBranch = dto.baseBranch || 'HEAD'; await this.executeGitCommand(agentEntity.containerId, `checkout -b ${this.escapePath(branchName)} ${baseBranch}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -1055,6 +1072,7 @@ export class AgentsVcsService { } await this.executeGitCommand(agentEntity.containerId, `branch -D ${this.escapePath(cleanBranchName)}`); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -1102,6 +1120,8 @@ export class AgentsVcsService { default: throw new BadRequestException(`Unknown conflict resolution strategy: ${dto.strategy}`); } + + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; @@ -1140,6 +1160,7 @@ export class AgentsVcsService { await this.executeGitCommand(containerId, `checkout ${escaped}`, this.BASE_PATH, false, true, true); await this.executeGitCommand(containerId, `reset --hard origin/${escaped}`, this.BASE_PATH, false, true, true); await this.executeGitCommand(containerId, 'clean -fd', this.BASE_PATH, false, true, true); + this.notifyGitStateMayHaveChanged(agentId); } catch (error: unknown) { const err = error as { message?: string }; diff --git a/libs/domains/framework/frontend/data-access-agent-console/docs/notifications-state.mmd b/libs/domains/framework/frontend/data-access-agent-console/docs/notifications-state.mmd new file mode 100644 index 00000000..6244c38d --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/docs/notifications-state.mmd @@ -0,0 +1,19 @@ +sequenceDiagram + participant UI as AgentConsole_UI + participant NgRx as notifications_store + participant SG as StatusGateway + participant Svc as AgentConsoleStatusService + + UI->>NgRx: connectNotificationsSocket + NgRx->>SG: connect (auth) + SG->>Svc: buildSnapshot(user) + Svc-->>SG: statusSnapshot + SG-->>NgRx: statusSnapshot + NgRx-->>UI: badges (spaces/workspaces/envs) + + Note over SG,NgRx: statusPatch on chat/automation/git change + SG-->>NgRx: statusPatch + NgRx-->>UI: play sound if not active env + + UI->>NgRx: markEnvironmentRead / setActiveEnvironment + NgRx->>SG: emit to server diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts index 13f4fc5a..07f381ff 100644 --- a/libs/domains/framework/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/framework/frontend/data-access-agent-console/src/index.ts @@ -189,6 +189,14 @@ export * from './lib/state/knowledge-board-socket/knowledge-board-socket.effects export * from './lib/state/knowledge-board-socket/knowledge-board-socket.facade'; export * from './lib/state/knowledge-board-socket/knowledge-board-socket.reducer'; export * from './lib/state/knowledge-board-socket/knowledge-board-socket.selectors'; +export * from './lib/state/notifications/notifications-attention.util'; +export * from './lib/state/notifications/notifications.actions'; +export * from './lib/state/notifications/notifications.effects'; +export * from './lib/state/notifications/notifications.facade'; +export * from './lib/state/notifications/notifications.reducer'; +export * from './lib/state/notifications/notifications.selectors'; +export * from './lib/state/notifications/notifications.types'; +export * from './lib/state/notifications/status-socket.constants'; export * from './lib/state/statistics/statistics.actions'; export * from './lib/state/statistics/statistics.effects'; export * from './lib/state/statistics/statistics.facade'; diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.spec.ts new file mode 100644 index 00000000..e9b8615f --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.spec.ts @@ -0,0 +1,32 @@ +import { resolveAttentionBadgeKind, resolveSpacesAttentionBadgeKind } from './notifications-attention.util'; + +describe('notifications attention util', () => { + describe('resolveAttentionBadgeKind', () => { + it('returns both when git is dirty and there are unread messages', () => { + expect(resolveAttentionBadgeKind(true, true)).toBe('both'); + }); + + it('returns git when only git is dirty', () => { + expect(resolveAttentionBadgeKind(true, false)).toBe('git'); + }); + + it('returns unread when only there are unread messages', () => { + expect(resolveAttentionBadgeKind(false, true)).toBe('unread'); + }); + + it('returns null when there is no attention', () => { + expect(resolveAttentionBadgeKind(false, false)).toBeNull(); + }); + }); + + describe('resolveSpacesAttentionBadgeKind', () => { + it('aggregates git and unread across clients', () => { + expect( + resolveSpacesAttentionBadgeKind([ + { gitDirty: true, hasUnreadMessages: false }, + { gitDirty: false, hasUnreadMessages: true }, + ]), + ).toBe('both'); + }); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.ts new file mode 100644 index 00000000..40373415 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-attention.util.ts @@ -0,0 +1,26 @@ +export type AttentionBadgeKind = 'git' | 'unread' | 'both'; + +export function resolveAttentionBadgeKind(gitDirty: boolean, hasUnread: boolean): AttentionBadgeKind | null { + if (gitDirty && hasUnread) { + return 'both'; + } + + if (gitDirty) { + return 'git'; + } + + if (hasUnread) { + return 'unread'; + } + + return null; +} + +export function resolveSpacesAttentionBadgeKind( + clients: ReadonlyArray<{ gitDirty: boolean; hasUnreadMessages: boolean }>, +): AttentionBadgeKind | null { + const anyGitDirty = clients.some((client) => client.gitDirty); + const anyUnread = clients.some((client) => client.hasUnreadMessages); + + return resolveAttentionBadgeKind(anyGitDirty, anyUnread); +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.spec.ts new file mode 100644 index 00000000..363df7ae --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.spec.ts @@ -0,0 +1,71 @@ +import { resolveStatusWebsocketUrl } from './notifications-websocket-url'; + +describe('resolveStatusWebsocketUrl', () => { + const baseEnvironment = { + production: false, + billing: { restApiUrl: '', frontendUrl: '' }, + authentication: { type: 'api-key' as const, apiKey: 'k' }, + chatModelOptions: {}, + editor: { openInNewWindow: false }, + deployment: { openInNewWindow: false }, + cookieConsent: { domain: '', privacyPolicyUrl: '', termsUrl: '' }, + }; + + it('derives /status from clients websocket url', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { restApiUrl: 'http://localhost:3000', websocketUrl: 'http://localhost:8081/clients' }, + }), + ).toBe('http://localhost:8081/status'); + }); + + it('uses explicit statusWebsocketUrl when set', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { + restApiUrl: 'http://localhost:3000', + websocketUrl: 'http://localhost:8081/clients', + statusWebsocketUrl: 'ws://custom/status', + }, + }), + ).toBe('ws://custom/status'); + }); + + it('returns null when no websocket url is configured', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { restApiUrl: 'http://localhost:3000' }, + }), + ).toBeNull(); + }); + + it('derives /status from a generic websocket base url', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { restApiUrl: 'http://localhost:3000', websocketUrl: 'ws://localhost:8081/ws' }, + }), + ).toBe('ws://localhost:8081/status'); + }); + + it('appends /status when websocket url is not a valid URL', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { restApiUrl: 'http://localhost:3000', websocketUrl: 'not-a-valid-url' }, + }), + ).toBe('not-a-valid-url/status'); + }); + + it('strips trailing slash before appending /status for invalid URLs', () => { + expect( + resolveStatusWebsocketUrl({ + ...baseEnvironment, + controller: { restApiUrl: 'http://localhost:3000', websocketUrl: 'not-a-valid-url/' }, + }), + ).toBe('not-a-valid-url/status'); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.ts new file mode 100644 index 00000000..7d9df5b6 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications-websocket-url.ts @@ -0,0 +1,27 @@ +import type { Environment } from '@forepath/framework/frontend/util-configuration'; + +export function resolveStatusWebsocketUrl(environment: Environment): string | null { + const explicit = environment.controller.statusWebsocketUrl?.trim(); + + if (explicit) { + return explicit; + } + + const base = environment.controller.websocketUrl?.trim(); + + if (!base) { + return null; + } + + if (base.endsWith('/clients')) { + return `${base.slice(0, -'/clients'.length)}/status`; + } + + try { + const u = new URL(base); + + return `${u.protocol}//${u.host}/status`; + } catch { + return `${base.replace(/\/$/, '')}/status`; + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.actions.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.actions.ts new file mode 100644 index 00000000..f5106c8f --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.actions.ts @@ -0,0 +1,50 @@ +import { createAction, props } from '@ngrx/store'; + +import type { ActiveEnvironment, StatusPatchPayload, StatusSnapshotPayload } from './notifications.types'; + +export const connectNotificationsSocket = createAction('[Notifications] Connect Socket'); +export const connectNotificationsSocketSuccess = createAction('[Notifications] Connect Socket Success'); +export const connectNotificationsSocketFailure = createAction( + '[Notifications] Connect Socket Failure', + props<{ error: string }>(), +); +export const disconnectNotificationsSocket = createAction('[Notifications] Disconnect Socket'); +export const disconnectNotificationsSocketSuccess = createAction('[Notifications] Disconnect Socket Success'); +export const notificationsSocketError = createAction('[Notifications] Socket Error', props<{ message: string }>()); +export const notificationsSocketReconnecting = createAction( + '[Notifications] Socket Reconnecting', + props<{ attempt: number }>(), +); +export const notificationsSocketReconnected = createAction('[Notifications] Socket Reconnected'); +export const notificationsSocketReconnectError = createAction( + '[Notifications] Socket Reconnect Error', + props<{ error: string }>(), +); +export const notificationsSocketReconnectFailed = createAction( + '[Notifications] Socket Reconnect Failed', + props<{ error: string }>(), +); + +export const statusSnapshotReceived = createAction( + '[Notifications] Status Snapshot Received', + props<{ snapshot: StatusSnapshotPayload }>(), +); +export const statusPatchReceived = createAction( + '[Notifications] Status Patch Received', + props<{ patch: StatusPatchPayload }>(), +); + +export const markEnvironmentRead = createAction( + '[Notifications] Mark Environment Read', + props<{ clientId: string; agentId: string }>(), +); +export const setActiveEnvironment = createAction( + '[Notifications] Set Active Environment', + props<{ clientId: string | null; agentId: string | null }>(), +); +export const setActiveEnvironmentLocal = createAction( + '[Notifications] Set Active Environment Local', + props<{ active: ActiveEnvironment | null }>(), +); + +export const playUnreadNotificationSound = createAction('[Notifications] Play Unread Sound'); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.spec.ts new file mode 100644 index 00000000..2b5cd7ef --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.spec.ts @@ -0,0 +1,355 @@ +import { KeycloakService } from 'keycloak-angular'; +import { Subject } from 'rxjs'; +import { io, Socket } from 'socket.io-client'; + +import { + connectNotificationsSocket, + connectNotificationsSocketFailure, + connectNotificationsSocketSuccess, + disconnectNotificationsSocket, + markEnvironmentRead, + notificationsSocketError, + notificationsSocketReconnected, + notificationsSocketReconnectError, + notificationsSocketReconnectFailed, + notificationsSocketReconnecting, + playUnreadNotificationSound, + setActiveEnvironment, + setActiveEnvironmentLocal, + statusPatchReceived, + statusSnapshotReceived, +} from './notifications.actions'; +import { + connectNotificationsSocket$, + disconnectNotificationsSocket$, + getStatusSocketInstance, + markEnvironmentRead$, + playUnreadNotificationSound$, + playUnreadSoundEffect$, + setActiveEnvironment$, +} from './notifications.effects'; +import { STATUS_SOCKET_EVENTS } from './status-socket.constants'; + +jest.mock('socket.io-client', () => ({ + io: jest.fn(), +})); +jest.mock('keycloak-angular', () => ({ + KeycloakService: jest.fn(), +})); + +const testEnvironment = { + production: false, + controller: { restApiUrl: 'http://localhost:3000', websocketUrl: 'http://localhost:8081/clients' }, + billing: { restApiUrl: '', frontendUrl: '' }, + authentication: { type: 'api-key' as const, apiKey: 'test-key' }, + chatModelOptions: {}, + editor: { openInNewWindow: false }, + deployment: { openInNewWindow: false }, + cookieConsent: { domain: '', privacyPolicyUrl: '', termsUrl: '' }, +}; + +describe('NotificationsEffects', () => { + let actions$: Subject>; + let mockSocket: jest.Mocked>; + const listeners = new Map void>(); + + beforeEach(() => { + listeners.clear(); + actions$ = new Subject(); + mockSocket = { + connected: true, + on: jest.fn((event: string, cb: (payload?: unknown) => void) => { + listeners.set(event, cb); + + return mockSocket as Socket; + }), + off: jest.fn(), + emit: jest.fn(), + disconnect: jest.fn(), + }; + + (io as jest.Mock).mockReturnValue(mockSocket as Socket); + }); + + it('connect effect fails when status websocket url is not configured', (done) => { + connectNotificationsSocket$( + actions$ as never, + { + ...testEnvironment, + controller: { restApiUrl: 'http://localhost:3000' }, + } as never, + null, + ).subscribe((action) => { + expect(action).toEqual(connectNotificationsSocketFailure({ error: 'Status WebSocket URL not configured' })); + done(); + }); + + actions$.next(connectNotificationsSocket()); + }); + + it('connect effect maps snapshot and patch socket events to ngrx actions', (done) => { + const received: unknown[] = []; + const sub = connectNotificationsSocket$(actions$ as never, testEnvironment as never, null).subscribe((action) => + received.push(action), + ); + + actions$.next(connectNotificationsSocket()); + listeners.get(STATUS_SOCKET_EVENTS.statusSnapshot)?.({ + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [], + clients: [], + spacesHasAttention: false, + }); + listeners.get(STATUS_SOCKET_EVENTS.statusPatch)?.({ + generatedAt: '2026-01-01T00:00:01.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + ], + }); + + setTimeout(() => { + expect(received).toContainEqual( + statusSnapshotReceived({ + snapshot: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [], + clients: [], + spacesHasAttention: false, + }, + }), + ); + expect(received).toContainEqual( + statusPatchReceived({ + patch: { + generatedAt: '2026-01-01T00:00:01.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + ], + }, + }), + ); + sub.unsubscribe(); + done(); + }, 0); + }); + + it('disconnect effect disconnects active socket', (done) => { + const disconnectActions$ = new Subject>(); + + connectNotificationsSocket$(actions$ as never, testEnvironment as never, null).subscribe(); + actions$.next(connectNotificationsSocket()); + + disconnectNotificationsSocket$(disconnectActions$ as never).subscribe(() => { + expect(mockSocket.disconnect).toHaveBeenCalled(); + expect(getStatusSocketInstance()).toBeNull(); + done(); + }); + + disconnectActions$.next(disconnectNotificationsSocket()); + disconnectActions$.complete(); + }); + + it('markEnvironmentRead effect emits socket event', () => { + const markRead$ = new Subject>(); + + connectNotificationsSocket$(actions$ as never, testEnvironment as never, null).subscribe(); + actions$.next(connectNotificationsSocket()); + markEnvironmentRead$(markRead$ as never).subscribe(); + + markRead$.next(markEnvironmentRead({ clientId: 'c1', agentId: 'a1' })); + + expect(mockSocket.emit).toHaveBeenCalledWith(STATUS_SOCKET_EVENTS.markEnvironmentRead, { + clientId: 'c1', + agentId: 'a1', + }); + }); + + it('setActiveEnvironment effect emits socket event and updates local state', () => { + const setActive$ = new Subject>(); + const dispatch = jest.fn(); + const fakeStore = { dispatch }; + + connectNotificationsSocket$(actions$ as never, testEnvironment as never, null).subscribe(); + actions$.next(connectNotificationsSocket()); + setActiveEnvironment$(setActive$ as never, fakeStore as never).subscribe(); + + setActive$.next(setActiveEnvironment({ clientId: 'c1', agentId: 'a1' })); + + expect(mockSocket.emit).toHaveBeenCalledWith(STATUS_SOCKET_EVENTS.setActiveEnvironment, { + clientId: 'c1', + agentId: 'a1', + }); + expect(dispatch).toHaveBeenCalledWith(setActiveEnvironmentLocal({ active: { clientId: 'c1', agentId: 'a1' } })); + }); + + it('connect effect uses api key from localStorage when env key is absent', async () => { + const getItem = jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('stored-key'); + + connectNotificationsSocket$( + actions$ as never, + { + ...testEnvironment, + authentication: { type: 'api-key' as const }, + } as never, + null, + ).subscribe(); + + actions$.next(connectNotificationsSocket()); + await Promise.resolve(); + + expect(io).toHaveBeenCalledWith( + 'http://localhost:8081/status', + expect.objectContaining({ + auth: { Authorization: 'Bearer stored-key' }, + }), + ); + getItem.mockRestore(); + }); + + it('connect effect uses keycloak token when configured', async () => { + const keycloak = { getToken: jest.fn().mockResolvedValue('kc-token') } as unknown as KeycloakService; + + connectNotificationsSocket$( + actions$ as never, + { + ...testEnvironment, + authentication: { type: 'keycloak' as const }, + } as never, + keycloak, + ).subscribe(); + + actions$.next(connectNotificationsSocket()); + await Promise.resolve(); + + expect(keycloak.getToken).toHaveBeenCalled(); + expect(io).toHaveBeenCalledWith( + 'http://localhost:8081/status', + expect.objectContaining({ + auth: { Authorization: 'Bearer kc-token' }, + }), + ); + }); + + it('connect effect maps reconnect and error socket events', (done) => { + const received: unknown[] = []; + const sub = connectNotificationsSocket$(actions$ as never, testEnvironment as never, null).subscribe((action) => + received.push(action), + ); + + actions$.next(connectNotificationsSocket()); + listeners.get('connect')?.(); + listeners.get('connect_error')?.(new Error('connect failed')); + listeners.get('reconnect_attempt')?.(2); + listeners.get('reconnect')?.(); + listeners.get('reconnect_error')?.(new Error('reconnect failed')); + listeners.get('reconnect_failed')?.(); + listeners.get('error')?.({ message: 'server error' }); + + setTimeout(() => { + expect(received).toContainEqual(connectNotificationsSocketSuccess()); + expect(received).toContainEqual(connectNotificationsSocketFailure({ error: 'connect failed' })); + expect(received).toContainEqual(notificationsSocketReconnecting({ attempt: 2 })); + expect(received).toContainEqual(notificationsSocketReconnected()); + expect(received).toContainEqual(notificationsSocketReconnectError({ error: 'reconnect failed' })); + expect(received).toContainEqual( + notificationsSocketReconnectFailed({ error: 'Reconnection failed after all attempts' }), + ); + expect(received).toContainEqual(notificationsSocketError({ message: 'server error' })); + sub.unsubscribe(); + done(); + }, 0); + }); + + it('playUnreadNotificationSound effect dispatches when no active environment', (done) => { + const patchActions$ = new Subject>(); + const active$ = new Subject<{ clientId: string; agentId: string } | null>(); + const fakeStore = { + select: jest.fn().mockReturnValue(active$), + }; + + playUnreadNotificationSound$(patchActions$ as never, fakeStore as never).subscribe((action) => { + expect(action).toEqual(playUnreadNotificationSound()); + done(); + }); + + active$.next(null); + patchActions$.next( + statusPatchReceived({ + patch: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + ], + }, + }), + ); + }); + + it('playUnreadSoundEffect plays notification audio', () => { + const play = jest.fn().mockResolvedValue(undefined); + const audioCtor = jest.fn().mockImplementation(() => ({ + volume: 0, + currentTime: 0, + play, + })); + + (global as { Audio?: typeof Audio }).Audio = audioCtor as unknown as typeof Audio; + + const soundActions$ = new Subject>(); + + playUnreadSoundEffect$(soundActions$ as never).subscribe(); + soundActions$.next(playUnreadNotificationSound()); + + expect(audioCtor).toHaveBeenCalledWith('/audio/notification-pling.wav'); + expect(play).toHaveBeenCalled(); + }); + + it('playUnreadNotificationSound effect dispatches when unread is outside active environment', (done) => { + const patchActions$ = new Subject>(); + const active$ = new Subject<{ clientId: string; agentId: string } | null>(); + const fakeStore = { + select: jest.fn().mockReturnValue(active$), + }; + + playUnreadNotificationSound$(patchActions$ as never, fakeStore as never).subscribe((action) => { + expect(action).toEqual(playUnreadNotificationSound()); + done(); + }); + + active$.next({ clientId: 'c-active', agentId: 'a-active' }); + patchActions$.next( + statusPatchReceived({ + patch: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [ + { + clientId: 'c-other', + agentId: 'a-other', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + ], + }, + }), + ); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.ts new file mode 100644 index 00000000..e8b014c2 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.effects.ts @@ -0,0 +1,248 @@ +import { inject } from '@angular/core'; +import type { Environment } from '@forepath/framework/frontend/util-configuration'; +import { ENVIRONMENT } from '@forepath/framework/frontend/util-configuration'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { KeycloakService } from 'keycloak-angular'; +import { catchError, filter, from, fromEvent, map, merge, Observable, of, switchMap, tap, withLatestFrom } from 'rxjs'; +import { io, Socket } from 'socket.io-client'; + +import { resolveStatusWebsocketUrl } from './notifications-websocket-url'; +import { + connectNotificationsSocket, + connectNotificationsSocketFailure, + connectNotificationsSocketSuccess, + disconnectNotificationsSocket, + disconnectNotificationsSocketSuccess, + markEnvironmentRead, + notificationsSocketError, + notificationsSocketReconnected, + notificationsSocketReconnectError, + notificationsSocketReconnectFailed, + notificationsSocketReconnecting, + playUnreadNotificationSound, + setActiveEnvironment, + setActiveEnvironmentLocal, + statusPatchReceived, + statusSnapshotReceived, +} from './notifications.actions'; +import { selectActiveEnvironment } from './notifications.selectors'; +import type { StatusPatchPayload, StatusSnapshotPayload } from './notifications.types'; +import { STATUS_SOCKET_EVENTS } from './status-socket.constants'; + +export { resolveStatusWebsocketUrl } from './notifications-websocket-url'; + +const API_KEY_STORAGE_KEY = 'agent-controller-api-key'; +const USERS_JWT_STORAGE_KEY = 'agent-controller-users-jwt'; +const NOTIFICATION_SOUND_URL = '/audio/notification-pling.wav'; + +function getAuthHeader(environment: Environment, keycloakService: KeycloakService | null): Observable { + if (environment.authentication.type === 'api-key') { + const apiKey = + environment.authentication.apiKey ?? + (typeof localStorage !== 'undefined' ? localStorage.getItem(API_KEY_STORAGE_KEY) : null); + + return of(apiKey ? `Bearer ${apiKey}` : null); + } + + if (environment.authentication.type === 'keycloak' && keycloakService) { + return from(keycloakService.getToken()).pipe( + map((token) => (token ? `Bearer ${token}` : null)), + catchError(() => of(null)), + ); + } + + if (environment.authentication.type === 'users') { + const jwt = typeof localStorage !== 'undefined' ? localStorage.getItem(USERS_JWT_STORAGE_KEY) : null; + + return of(jwt ? `Bearer ${jwt}` : null); + } + + return of(null); +} + +let statusSocketInstance: Socket | null = null; +let notificationAudio: HTMLAudioElement | null = null; + +export function getStatusSocketInstance(): Socket | null { + return statusSocketInstance; +} + +function playNotificationSound(): void { + if (typeof Audio === 'undefined') { + return; + } + + if (!notificationAudio) { + notificationAudio = new Audio(NOTIFICATION_SOUND_URL); + notificationAudio.volume = 0.5; + } + + notificationAudio.currentTime = 0; + void notificationAudio.play().catch(() => undefined); +} + +export const connectNotificationsSocket$ = createEffect( + ( + actions$ = inject(Actions), + environment = inject(ENVIRONMENT), + keycloakService = inject(KeycloakService, { optional: true }), + ) => + actions$.pipe( + ofType(connectNotificationsSocket), + switchMap(() => { + const websocketUrl = resolveStatusWebsocketUrl(environment); + + if (!websocketUrl) { + return of(connectNotificationsSocketFailure({ error: 'Status WebSocket URL not configured' })); + } + + if (statusSocketInstance) { + statusSocketInstance.disconnect(); + statusSocketInstance = null; + } + + return getAuthHeader(environment, keycloakService).pipe( + switchMap((authHeader) => { + statusSocketInstance = io(websocketUrl, { + transports: ['websocket'], + rejectUnauthorized: false, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, + randomizationFactor: 0.5, + ...(authHeader && { auth: { Authorization: authHeader } }), + }); + + const connectSuccess$ = fromEvent(statusSocketInstance, 'connect').pipe( + map(() => connectNotificationsSocketSuccess()), + ); + const connectError$ = fromEvent(statusSocketInstance, 'connect_error').pipe( + map((error) => connectNotificationsSocketFailure({ error: error.message || 'Connection error' })), + ); + const reconnectAttempt$ = merge( + fromEvent(statusSocketInstance, 'reconnect_attempt'), + fromEvent(statusSocketInstance, 'reconnecting'), + ).pipe(map((attempt) => notificationsSocketReconnecting({ attempt }))); + const reconnect$ = fromEvent(statusSocketInstance, 'reconnect').pipe( + map(() => notificationsSocketReconnected()), + ); + const reconnectError$ = fromEvent(statusSocketInstance, 'reconnect_error').pipe( + map((error) => notificationsSocketReconnectError({ error: error.message || 'Reconnection error' })), + ); + const reconnectFailed$ = fromEvent(statusSocketInstance, 'reconnect_failed').pipe( + map(() => { + statusSocketInstance = null; + + return notificationsSocketReconnectFailed({ error: 'Reconnection failed after all attempts' }); + }), + ); + const snapshot$ = fromEvent( + statusSocketInstance, + STATUS_SOCKET_EVENTS.statusSnapshot, + ).pipe(map((snapshot) => statusSnapshotReceived({ snapshot }))); + const patch$ = fromEvent(statusSocketInstance, STATUS_SOCKET_EVENTS.statusPatch).pipe( + map((patch) => statusPatchReceived({ patch })), + ); + const error$ = fromEvent<{ message: string }>(statusSocketInstance, 'error').pipe( + map((data) => notificationsSocketError({ message: data.message })), + ); + + return merge( + connectSuccess$, + connectError$, + reconnectAttempt$, + reconnect$, + reconnectError$, + reconnectFailed$, + snapshot$, + patch$, + error$, + ); + }), + ); + }), + ), + { functional: true, dispatch: true }, +); + +export const disconnectNotificationsSocket$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(disconnectNotificationsSocket), + tap(() => { + if (statusSocketInstance) { + statusSocketInstance.disconnect(); + statusSocketInstance = null; + } + }), + map(() => disconnectNotificationsSocketSuccess()), + ), + { functional: true, dispatch: true }, +); + +export const markEnvironmentRead$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(markEnvironmentRead), + tap(({ clientId, agentId }) => { + statusSocketInstance?.emit(STATUS_SOCKET_EVENTS.markEnvironmentRead, { clientId, agentId }); + }), + ), + { functional: true, dispatch: false }, +); + +export const setActiveEnvironment$ = createEffect( + (actions$ = inject(Actions), store = inject(Store)) => + actions$.pipe( + ofType(setActiveEnvironment), + tap(({ clientId, agentId }) => { + statusSocketInstance?.emit(STATUS_SOCKET_EVENTS.setActiveEnvironment, { clientId, agentId }); + store.dispatch( + setActiveEnvironmentLocal({ + active: clientId && agentId ? { clientId, agentId } : null, + }), + ); + }), + ), + { functional: true, dispatch: false }, +); + +export const playUnreadNotificationSound$ = createEffect( + (actions$ = inject(Actions), store = inject(Store)) => + actions$.pipe( + ofType(statusPatchReceived), + withLatestFrom(store.select(selectActiveEnvironment)), + filter(([{ patch }, active]) => { + const changed = patch.environments?.some((e) => e.hasUnreadMessages) ?? false; + + if (!changed || !patch.environments?.length) { + return false; + } + + return patch.environments.some((env) => { + if (!env.hasUnreadMessages) { + return false; + } + + if (!active) { + return true; + } + + return active.clientId !== env.clientId || active.agentId !== env.agentId; + }); + }), + map(() => playUnreadNotificationSound()), + ), + { functional: true, dispatch: true }, +); + +export const playUnreadSoundEffect$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(playUnreadNotificationSound), + tap(() => playNotificationSound()), + ), + { functional: true, dispatch: false }, +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.spec.ts new file mode 100644 index 00000000..400cf6bd --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.spec.ts @@ -0,0 +1,101 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { firstValueFrom } from 'rxjs'; + +import { + connectNotificationsSocket, + disconnectNotificationsSocket, + markEnvironmentRead, + setActiveEnvironment, +} from './notifications.actions'; +import { NotificationsFacade } from './notifications.facade'; +import type { NotificationsState } from './notifications.reducer'; + +describe('NotificationsFacade', () => { + let facade: NotificationsFacade; + let store: MockStore<{ notifications: NotificationsState }>; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + NotificationsFacade, + provideMockStore({ + initialState: { + notifications: { + spacesHasAttention: true, + socketConnected: false, + socketConnecting: false, + socketError: null, + environmentsByKey: { + 'c1:a1': { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + }, + clientsById: { + c1: { clientId: 'c1', hasUnreadMessages: true, gitDirty: true }, + }, + activeEnvironment: null, + }, + }, + }), + ], + }); + + facade = TestBed.inject(NotificationsFacade); + store = TestBed.inject(MockStore); + }); + + it('should be created', () => { + expect(facade).toBeTruthy(); + }); + + it('connectSocket dispatches connectNotificationsSocket', () => { + const spy = jest.spyOn(store, 'dispatch'); + + facade.connectSocket(); + + expect(spy).toHaveBeenCalledWith(connectNotificationsSocket()); + }); + + it('disconnectSocket dispatches disconnectNotificationsSocket', () => { + const spy = jest.spyOn(store, 'dispatch'); + + facade.disconnectSocket(); + + expect(spy).toHaveBeenCalledWith(disconnectNotificationsSocket()); + }); + + it('markEnvironmentRead dispatches action with ids', () => { + const spy = jest.spyOn(store, 'dispatch'); + + facade.markEnvironmentRead('c1', 'a1'); + + expect(spy).toHaveBeenCalledWith(markEnvironmentRead({ clientId: 'c1', agentId: 'a1' })); + }); + + it('setActiveEnvironment dispatches action', () => { + const spy = jest.spyOn(store, 'dispatch'); + + facade.setActiveEnvironment('c1', 'a1'); + + expect(spy).toHaveBeenCalledWith(setActiveEnvironment({ clientId: 'c1', agentId: 'a1' })); + }); + + it('exposes attention and status selectors', async () => { + expect(await firstValueFrom(facade.spacesAttentionBadge$)).toBe('both'); + expect(await firstValueFrom(facade.getClientHasUnread$('c1'))).toBe(true); + expect(await firstValueFrom(facade.getClientGitDirty$('c1'))).toBe(true); + expect(await firstValueFrom(facade.getEnvironmentHasUnread$('c1', 'a1'))).toBe(true); + expect(await firstValueFrom(facade.getEnvironmentGitDirty$('c1', 'a1'))).toBe(false); + expect(await firstValueFrom(facade.getClientAttentionBadge$('c1'))).toBe('both'); + expect(await firstValueFrom(facade.getEnvironmentAttentionBadge$('c1', 'a1'))).toBe('unread'); + expect(await firstValueFrom(facade.getEnvironmentStatus$('c1', 'a1'))).toMatchObject({ + clientId: 'c1', + agentId: 'a1', + }); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.ts new file mode 100644 index 00000000..66de763b --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.facade.ts @@ -0,0 +1,74 @@ +import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import type { AttentionBadgeKind } from './notifications-attention.util'; +import { + connectNotificationsSocket, + disconnectNotificationsSocket, + markEnvironmentRead, + setActiveEnvironment, +} from './notifications.actions'; +import { + selectClientAttentionBadge, + selectClientGitDirty, + selectClientHasUnread, + selectEnvironmentAttentionBadge, + selectEnvironmentGitDirty, + selectEnvironmentHasUnread, + selectEnvironmentStatus, + selectSpacesAttentionBadge, + selectSpacesHasAttention, +} from './notifications.selectors'; + +@Injectable({ providedIn: 'root' }) +export class NotificationsFacade { + private readonly store = inject(Store); + + readonly spacesHasAttention$ = this.store.select(selectSpacesHasAttention); + readonly spacesAttentionBadge$ = this.store.select(selectSpacesAttentionBadge); + + connectSocket(): void { + this.store.dispatch(connectNotificationsSocket()); + } + + disconnectSocket(): void { + this.store.dispatch(disconnectNotificationsSocket()); + } + + markEnvironmentRead(clientId: string, agentId: string): void { + this.store.dispatch(markEnvironmentRead({ clientId, agentId })); + } + + setActiveEnvironment(clientId: string | null, agentId: string | null): void { + this.store.dispatch(setActiveEnvironment({ clientId, agentId })); + } + + getClientHasUnread$(clientId: string): Observable { + return this.store.select(selectClientHasUnread(clientId)); + } + + getClientGitDirty$(clientId: string): Observable { + return this.store.select(selectClientGitDirty(clientId)); + } + + getEnvironmentHasUnread$(clientId: string, agentId: string): Observable { + return this.store.select(selectEnvironmentHasUnread(clientId, agentId)); + } + + getEnvironmentGitDirty$(clientId: string, agentId: string): Observable { + return this.store.select(selectEnvironmentGitDirty(clientId, agentId)); + } + + getEnvironmentStatus$(clientId: string, agentId: string) { + return this.store.select(selectEnvironmentStatus(clientId, agentId)); + } + + getClientAttentionBadge$(clientId: string): Observable { + return this.store.select(selectClientAttentionBadge(clientId)); + } + + getEnvironmentAttentionBadge$(clientId: string, agentId: string): Observable { + return this.store.select(selectEnvironmentAttentionBadge(clientId, agentId)); + } +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.spec.ts new file mode 100644 index 00000000..8cc44e64 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.spec.ts @@ -0,0 +1,198 @@ +import { + connectNotificationsSocket, + connectNotificationsSocketFailure, + connectNotificationsSocketSuccess, + disconnectNotificationsSocket, + disconnectNotificationsSocketSuccess, + notificationsSocketError, + notificationsSocketReconnected, + notificationsSocketReconnectError, + notificationsSocketReconnectFailed, + notificationsSocketReconnecting, + setActiveEnvironmentLocal, + statusPatchReceived, + statusSnapshotReceived, +} from './notifications.actions'; +import { initialNotificationsState, notificationsReducer } from './notifications.reducer'; + +describe('notificationsReducer', () => { + it('applies status snapshot', () => { + const state = notificationsReducer( + initialNotificationsState, + statusSnapshotReceived({ + snapshot: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: false, + gitConflict: false, + }, + ], + clients: [{ clientId: 'c1', hasUnreadMessages: true, gitDirty: false }], + spacesHasAttention: true, + }, + }), + ); + + expect(state.environmentsByKey['c1:a1']?.hasUnreadMessages).toBe(true); + expect(state.spacesHasAttention).toBe(true); + }); + + it('merges status patch for environment', () => { + const withSnapshot = notificationsReducer( + initialNotificationsState, + statusSnapshotReceived({ + snapshot: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: false, + gitDirty: false, + gitConflict: false, + }, + ], + clients: [{ clientId: 'c1', hasUnreadMessages: false, gitDirty: false }], + spacesHasAttention: false, + }, + }), + ); + const state = notificationsReducer( + withSnapshot, + statusPatchReceived({ + patch: { + generatedAt: '2026-01-02T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: true, + gitConflict: false, + }, + ], + }, + }), + ); + + expect(state.environmentsByKey['c1:a1']?.hasUnreadMessages).toBe(true); + expect(state.environmentsByKey['c1:a1']?.gitDirty).toBe(true); + expect(state.clientsById['c1']?.gitDirty).toBe(true); + }); + + it('keeps workspace attention when patch carries a single-environment client rollup', () => { + const withSnapshot = notificationsReducer( + initialNotificationsState, + statusSnapshotReceived({ + snapshot: { + generatedAt: '2026-01-01T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: true, + gitConflict: false, + }, + { + clientId: 'c1', + agentId: 'a2', + hasUnreadMessages: false, + gitDirty: false, + gitConflict: false, + }, + ], + clients: [{ clientId: 'c1', hasUnreadMessages: true, gitDirty: true }], + spacesHasAttention: true, + }, + }), + ); + const state = notificationsReducer( + withSnapshot, + statusPatchReceived({ + patch: { + generatedAt: '2026-01-02T00:00:00.000Z', + environments: [ + { + clientId: 'c1', + agentId: 'a2', + hasUnreadMessages: false, + gitDirty: false, + gitConflict: false, + }, + ], + clients: [{ clientId: 'c1', hasUnreadMessages: false, gitDirty: false }], + spacesHasAttention: false, + }, + }), + ); + + expect(state.clientsById['c1']).toEqual({ + clientId: 'c1', + hasUnreadMessages: true, + gitDirty: true, + }); + expect(state.spacesHasAttention).toBe(true); + }); + + it('applies client-only status patch when no environments are present', () => { + const state = notificationsReducer( + initialNotificationsState, + statusPatchReceived({ + patch: { + generatedAt: '2026-01-02T00:00:00.000Z', + clients: [{ clientId: 'c1', hasUnreadMessages: true, gitDirty: false }], + }, + }), + ); + + expect(state.clientsById['c1']?.hasUnreadMessages).toBe(true); + expect(state.spacesHasAttention).toBe(true); + }); + + it('tracks socket lifecycle and active environment', () => { + let state = notificationsReducer(initialNotificationsState, connectNotificationsSocket()); + + expect(state.socketConnecting).toBe(true); + expect(state.socketError).toBeNull(); + + state = notificationsReducer(state, connectNotificationsSocketSuccess()); + expect(state.socketConnected).toBe(true); + expect(state.socketConnecting).toBe(false); + + state = notificationsReducer(state, notificationsSocketReconnecting({ attempt: 1 })); + expect(state.socketConnecting).toBe(true); + + state = notificationsReducer(state, notificationsSocketReconnected()); + expect(state.socketConnected).toBe(true); + expect(state.socketConnecting).toBe(false); + + state = notificationsReducer(state, notificationsSocketReconnectError({ error: 'retry failed' })); + expect(state.socketError).toBe('retry failed'); + + state = notificationsReducer(state, notificationsSocketReconnectFailed({ error: 'gave up' })); + expect(state.socketConnected).toBe(false); + expect(state.socketError).toBe('gave up'); + + state = notificationsReducer(state, notificationsSocketError({ message: 'socket error' })); + expect(state.socketError).toBe('socket error'); + + state = notificationsReducer(state, setActiveEnvironmentLocal({ active: { clientId: 'c1', agentId: 'a1' } })); + expect(state.activeEnvironment).toEqual({ clientId: 'c1', agentId: 'a1' }); + + state = notificationsReducer(state, disconnectNotificationsSocket()); + state = notificationsReducer(state, disconnectNotificationsSocketSuccess()); + expect(state).toEqual(initialNotificationsState); + + state = notificationsReducer( + initialNotificationsState, + connectNotificationsSocketFailure({ error: 'failed to connect' }), + ); + expect(state.socketError).toBe('failed to connect'); + expect(state.socketConnected).toBe(false); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.ts new file mode 100644 index 00000000..e238e509 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.reducer.ts @@ -0,0 +1,155 @@ +import { createReducer, on } from '@ngrx/store'; + +import { + connectNotificationsSocket, + connectNotificationsSocketFailure, + connectNotificationsSocketSuccess, + disconnectNotificationsSocket, + disconnectNotificationsSocketSuccess, + notificationsSocketError, + notificationsSocketReconnected, + notificationsSocketReconnectError, + notificationsSocketReconnectFailed, + notificationsSocketReconnecting, + setActiveEnvironmentLocal, + statusPatchReceived, + statusSnapshotReceived, +} from './notifications.actions'; +import type { ClientStatus, EnvironmentStatus } from './notifications.types'; + +export interface NotificationsState { + socketConnected: boolean; + socketConnecting: boolean; + socketError: string | null; + environmentsByKey: Record; + clientsById: Record; + spacesHasAttention: boolean; + activeEnvironment: { clientId: string; agentId: string } | null; +} + +export const initialNotificationsState: NotificationsState = { + socketConnected: false, + socketConnecting: false, + socketError: null, + environmentsByKey: {}, + clientsById: {}, + spacesHasAttention: false, + activeEnvironment: null, +}; + +function envKey(clientId: string, agentId: string): string { + return `${clientId}:${agentId}`; +} + +function recomputeSpacesAttention(clientsById: Record): boolean { + return Object.values(clientsById).some((c) => c.hasUnreadMessages || c.gitDirty); +} + +function rebuildClientsFromEnvironments( + environmentsByKey: Record, +): Record { + const clients: Record = {}; + + for (const env of Object.values(environmentsByKey)) { + const existing = clients[env.clientId] ?? { + clientId: env.clientId, + hasUnreadMessages: false, + gitDirty: false, + }; + + clients[env.clientId] = { + clientId: env.clientId, + hasUnreadMessages: existing.hasUnreadMessages || env.hasUnreadMessages, + gitDirty: existing.gitDirty || env.gitDirty, + }; + } + + return clients; +} + +export const notificationsReducer = createReducer( + initialNotificationsState, + on(connectNotificationsSocket, (state) => ({ + ...state, + socketConnecting: true, + socketError: null, + })), + on(connectNotificationsSocketSuccess, (state) => ({ + ...state, + socketConnected: true, + socketConnecting: false, + socketError: null, + })), + on(connectNotificationsSocketFailure, (state, { error }) => ({ + ...initialNotificationsState, + socketError: error, + })), + on(disconnectNotificationsSocket, (state) => ({ ...state, socketConnecting: false })), + on(disconnectNotificationsSocketSuccess, () => ({ ...initialNotificationsState })), + on(notificationsSocketReconnecting, (state) => ({ ...state, socketConnecting: true })), + on(notificationsSocketReconnected, (state) => ({ + ...state, + socketConnected: true, + socketConnecting: false, + })), + on(notificationsSocketReconnectError, (state, { error }) => ({ ...state, socketError: error })), + on(notificationsSocketReconnectFailed, (state, { error }) => ({ + ...state, + socketConnected: false, + socketConnecting: false, + socketError: error, + })), + on(notificationsSocketError, (state, { message }) => ({ ...state, socketError: message })), + on(statusSnapshotReceived, (state, { snapshot }) => { + const environmentsByKey: Record = {}; + + for (const env of snapshot.environments) { + environmentsByKey[envKey(env.clientId, env.agentId)] = env; + } + + const clientsById: Record = {}; + + for (const client of snapshot.clients) { + clientsById[client.clientId] = client; + } + + return { + ...state, + environmentsByKey, + clientsById, + spacesHasAttention: snapshot.spacesHasAttention, + }; + }), + on(statusPatchReceived, (state, { patch }) => { + const environmentsByKey = { ...state.environmentsByKey }; + + if (patch.environments?.length) { + for (const env of patch.environments) { + environmentsByKey[envKey(env.clientId, env.agentId)] = env; + } + } + + let clientsById = { ...state.clientsById }; + + if (patch.environments?.length && Object.keys(environmentsByKey).length > 0) { + clientsById = rebuildClientsFromEnvironments(environmentsByKey); + } else if (patch.clients?.length) { + for (const client of patch.clients) { + clientsById[client.clientId] = client; + } + } + + const spacesHasAttention = recomputeSpacesAttention(clientsById); + + return { + ...state, + environmentsByKey, + clientsById, + spacesHasAttention, + }; + }), + on(setActiveEnvironmentLocal, (state, { active }) => ({ + ...state, + activeEnvironment: active, + })), +); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.spec.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.spec.ts new file mode 100644 index 00000000..adf33e22 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.spec.ts @@ -0,0 +1,83 @@ +import { initialNotificationsState } from './notifications.reducer'; +import { + selectActiveEnvironment, + selectClientAttentionBadge, + selectClientGitDirty, + selectClientHasUnread, + selectClientStatus, + selectEnvironmentAttentionBadge, + selectEnvironmentGitDirty, + selectEnvironmentHasUnread, + selectEnvironmentStatus, + selectSpacesAttentionBadge, + selectSpacesHasAttention, +} from './notifications.selectors'; + +describe('notifications selectors', () => { + const baseState = { + notifications: { + ...initialNotificationsState, + environmentsByKey: { + 'c1:a1': { + clientId: 'c1', + agentId: 'a1', + hasUnreadMessages: true, + gitDirty: true, + gitConflict: false, + }, + }, + clientsById: { + c1: { clientId: 'c1', hasUnreadMessages: true, gitDirty: true }, + }, + spacesHasAttention: true, + activeEnvironment: { clientId: 'c1', agentId: 'a1' }, + }, + }; + + it('selectSpacesHasAttention', () => { + expect(selectSpacesHasAttention(baseState)).toBe(true); + }); + + it('selectSpacesAttentionBadge', () => { + expect(selectSpacesAttentionBadge(baseState)).toBe('both'); + }); + + it('selectClientAttentionBadge', () => { + expect(selectClientAttentionBadge('c1')(baseState)).toBe('both'); + }); + + it('selectEnvironmentAttentionBadge', () => { + expect(selectEnvironmentAttentionBadge('c1', 'a1')(baseState)).toBe('both'); + }); + + it('selectActiveEnvironment', () => { + expect(selectActiveEnvironment(baseState)).toEqual({ clientId: 'c1', agentId: 'a1' }); + }); + + it('selectEnvironmentStatus', () => { + const selector = selectEnvironmentStatus('c1', 'a1'); + + expect(selector(baseState)?.hasUnreadMessages).toBe(true); + expect(selector(baseState)?.gitDirty).toBe(true); + }); + + it('selectClientStatus', () => { + expect(selectClientStatus('c1')(baseState)?.hasUnreadMessages).toBe(true); + }); + + it('selectClientHasUnread returns false when unknown client', () => { + expect(selectClientHasUnread('unknown')(baseState)).toBe(false); + }); + + it('selectClientGitDirty', () => { + expect(selectClientGitDirty('c1')(baseState)).toBe(true); + }); + + it('selectEnvironmentHasUnread', () => { + expect(selectEnvironmentHasUnread('c1', 'a1')(baseState)).toBe(true); + }); + + it('selectEnvironmentGitDirty', () => { + expect(selectEnvironmentGitDirty('c1', 'a1')(baseState)).toBe(true); + }); +}); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.ts new file mode 100644 index 00000000..cc0616a3 --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.selectors.ts @@ -0,0 +1,44 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { resolveAttentionBadgeKind, resolveSpacesAttentionBadgeKind } from './notifications-attention.util'; +import type { NotificationsState } from './notifications.reducer'; + +export const selectNotificationsState = createFeatureSelector('notifications'); + +export const selectSpacesHasAttention = createSelector(selectNotificationsState, (state) => state.spacesHasAttention); + +export const selectSpacesAttentionBadge = createSelector(selectNotificationsState, (state) => + resolveSpacesAttentionBadgeKind(Object.values(state.clientsById)), +); + +export const selectActiveEnvironment = createSelector(selectNotificationsState, (state) => state.activeEnvironment); + +export const selectEnvironmentStatus = (clientId: string, agentId: string) => + createSelector(selectNotificationsState, (state) => state.environmentsByKey[`${clientId}:${agentId}`] ?? null); + +export const selectClientStatus = (clientId: string) => + createSelector(selectNotificationsState, (state) => state.clientsById[clientId] ?? null); + +export const selectClientHasUnread = (clientId: string) => + createSelector(selectClientStatus(clientId), (client) => client?.hasUnreadMessages ?? false); + +export const selectClientGitDirty = (clientId: string) => + createSelector(selectClientStatus(clientId), (client) => client?.gitDirty ?? false); + +export const selectEnvironmentHasUnread = (clientId: string, agentId: string) => + createSelector(selectEnvironmentStatus(clientId, agentId), (env) => env?.hasUnreadMessages ?? false); + +export const selectEnvironmentGitDirty = (clientId: string, agentId: string) => + createSelector(selectEnvironmentStatus(clientId, agentId), (env) => env?.gitDirty ?? false); + +export const selectClientAttentionBadge = (clientId: string) => + createSelector(selectClientGitDirty(clientId), selectClientHasUnread(clientId), (gitDirty, hasUnread) => + resolveAttentionBadgeKind(gitDirty, hasUnread), + ); + +export const selectEnvironmentAttentionBadge = (clientId: string, agentId: string) => + createSelector( + selectEnvironmentGitDirty(clientId, agentId), + selectEnvironmentHasUnread(clientId, agentId), + (gitDirty, hasUnread) => resolveAttentionBadgeKind(gitDirty, hasUnread), + ); diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.types.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.types.ts new file mode 100644 index 00000000..3ca2647c --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/notifications.types.ts @@ -0,0 +1,32 @@ +export interface EnvironmentStatus { + clientId: string; + agentId: string; + hasUnreadMessages: boolean; + gitDirty: boolean; + gitConflict: boolean; +} + +export interface ClientStatus { + clientId: string; + hasUnreadMessages: boolean; + gitDirty: boolean; +} + +export interface StatusSnapshotPayload { + generatedAt: string; + environments: EnvironmentStatus[]; + clients: ClientStatus[]; + spacesHasAttention: boolean; +} + +export interface StatusPatchPayload { + generatedAt: string; + environments?: EnvironmentStatus[]; + clients?: ClientStatus[]; + spacesHasAttention?: boolean; +} + +export interface ActiveEnvironment { + clientId: string; + agentId: string; +} diff --git a/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/status-socket.constants.ts b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/status-socket.constants.ts new file mode 100644 index 00000000..f6c1e66b --- /dev/null +++ b/libs/domains/framework/frontend/data-access-agent-console/src/lib/state/notifications/status-socket.constants.ts @@ -0,0 +1,6 @@ +export const STATUS_SOCKET_EVENTS = { + statusSnapshot: 'statusSnapshot', + statusPatch: 'statusPatch', + markEnvironmentRead: 'markEnvironmentRead', + setActiveEnvironment: 'setActiveEnvironment', +} as const; 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 93ea0285..1dd51c60 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 @@ -16,6 +16,7 @@ import { clearExternalImportMarkers$, commit$, connectKnowledgeBoardSocket$, + connectNotificationsSocket$, connectSocket$, connectTicketsBoardSocket$, createBranch$, @@ -48,6 +49,7 @@ import { DeploymentsFacade, deploymentsReducer, disconnectKnowledgeBoardSocket$, + disconnectNotificationsSocket$, disconnectSocket$, disconnectTicketsBoardSocket$, duplicateKnowledgeNode$, @@ -115,8 +117,14 @@ import { loadTickets$, loadWorkflows$, loadWorkspaceConfigurationOverrides$, + markEnvironmentRead$, migrateTicket$, moveFileOrDirectory$, + NotificationsFacade, + notificationsReducer, + playUnreadNotificationSound$, + playUnreadSoundEffect$, + setActiveEnvironment$, openTicketDetail$, patchTicketAutomation$, processContainerStats$, @@ -312,6 +320,7 @@ export const agentConsoleRoutes: Route[] = [ FilterRulesFacade, AtlassianContextImportFacade, WorkspaceConfigFacade, + NotificationsFacade, // Feature states - registered at feature level for lazy loading provideState('clients', clientsReducer), provideState('agents', agentsReducer), @@ -331,6 +340,7 @@ export const agentConsoleRoutes: Route[] = [ provideState('filterRules', filterRulesReducer), provideState('atlassianContextImport', atlassianContextImportReducer), provideState('workspaceConfig', workspaceConfigReducer), + provideState('notifications', notificationsReducer), // Effects - only active when this feature route is loaded provideEffects({ loadClients$, @@ -367,7 +377,13 @@ export const agentConsoleRoutes: Route[] = [ restoreClientContext$, restoreAgentLogin$, connectKnowledgeBoardSocket$, + connectNotificationsSocket$, disconnectKnowledgeBoardSocket$, + disconnectNotificationsSocket$, + markEnvironmentRead$, + setActiveEnvironment$, + playUnreadNotificationSound$, + playUnreadSoundEffect$, restoreKnowledgeBoardSocketClient$, syncTicketAutomationRunFromClientsChat$, syncTicketsFromClientsChatTicketUpsert$, 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 dab3af84..a92fdf76 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 @@ -179,8 +179,21 @@
Workspaces
(click)="onClientSelect(client.id)" >
-
+
{{ client.name }} + @if (getClientAttentionBadge$(client.id) | async; as attentionBadge) { + + }
@if (client.description) { {{ client.description }} @@ -340,6 +353,22 @@
Environments
{{ agent.name }} @if (activeClient$ | async; as activeClient) { + @if ( + getEnvironmentAttentionBadge$(activeClient.id, agent.id) | async; + as attentionBadge + ) { + + } @if (getAgentDeploymentStatus$(activeClient.id, agent.id) | async; as deploymentStatus) { @if (deploymentStatus) { @@ -664,6 +693,7 @@
Chat