From 60ad0cba1a0aac4ce61bad829682fa8ab0ba16c0 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 19:23:56 +0000 Subject: [PATCH 01/15] feat(notifications): add redis gateway stack --- README.md | 1 + docker-compose.e2e.yml | 159 ++++ docs/runbooks/notifications-gateway.md | 53 ++ ops/envoy/envoy.yaml | 72 ++ packages/notifications-gateway/Dockerfile | 51 ++ packages/notifications-gateway/README.md | 26 + packages/notifications-gateway/package.json | 24 + packages/notifications-gateway/src/config.ts | 41 + packages/notifications-gateway/src/index.ts | 76 ++ packages/notifications-gateway/src/logger.ts | 10 + .../src/redis/notifications-subscriber.ts | 74 ++ .../notifications-gateway/src/redis/schema.ts | 14 + packages/notifications-gateway/src/rooms.ts | 11 + .../src/socket/server.ts | 24 + .../src/socket/subscriptions.ts | 56 ++ packages/notifications-gateway/tsconfig.json | 21 + packages/platform-server/.env.example | 4 + packages/platform-server/README.md | 9 +- .../__e2e__/app.bootstrap.smoke.test.ts | 35 +- .../__e2e__/graph.socket.gateway.e2e.test.ts | 686 ----------------- .../llmSettings.adminStatus.e2e.test.ts | 6 + .../__e2e__/llmSettings.models.e2e.test.ts | 6 + .../__e2e__/llmProvisioner.bootstrap.test.ts | 2 + .../__tests__/app.module.smoke.test.ts | 15 + .../__tests__/config.service.fromEnv.test.ts | 6 + .../__tests__/graph.module.di.smoke.test.ts | 20 +- .../graph.socket.gateway.bus.test.ts | 307 -------- .../__tests__/helpers/config.ts | 2 + .../mcp.enabledTools.boot.integration.test.ts | 4 +- .../notifications.publisher.bus.test.ts | 254 +++++++ .../__tests__/run-events.publish.test.ts | 50 +- .../__tests__/socket.events.test.ts | 132 ---- .../__tests__/socket.gateway.test.ts | 30 - .../__tests__/socket.metrics.coalesce.test.ts | 90 ++- .../socket.node_status.integration.test.ts | 40 - .../socket.realtime.integration.test.ts | 348 --------- .../sql.threads.metrics.queries.test.ts | 11 +- packages/platform-server/package.json | 3 +- .../src/bootstrap/app.module.ts | 4 +- .../src/core/services/config.service.ts | 19 + .../src/gateway/gateway.module.ts | 11 - .../src/gateway/graph.socket.gateway.ts | 719 ------------------ packages/platform-server/src/index.ts | 5 - .../src/notifications/notifications.broker.ts | 39 + .../src/notifications/notifications.module.ts | 11 + .../notifications/notifications.publisher.ts | 584 ++++++++++++++ .../notifications/notifications.schemas.ts | 72 ++ packages/shared/src/index.ts | 2 + packages/shared/src/notifications/index.ts | 28 + pnpm-lock.yaml | 112 ++- 50 files changed, 1985 insertions(+), 2394 deletions(-) create mode 100644 docker-compose.e2e.yml create mode 100644 docs/runbooks/notifications-gateway.md create mode 100644 ops/envoy/envoy.yaml create mode 100644 packages/notifications-gateway/Dockerfile create mode 100644 packages/notifications-gateway/README.md create mode 100644 packages/notifications-gateway/package.json create mode 100644 packages/notifications-gateway/src/config.ts create mode 100644 packages/notifications-gateway/src/index.ts create mode 100644 packages/notifications-gateway/src/logger.ts create mode 100644 packages/notifications-gateway/src/redis/notifications-subscriber.ts create mode 100644 packages/notifications-gateway/src/redis/schema.ts create mode 100644 packages/notifications-gateway/src/rooms.ts create mode 100644 packages/notifications-gateway/src/socket/server.ts create mode 100644 packages/notifications-gateway/src/socket/subscriptions.ts create mode 100644 packages/notifications-gateway/tsconfig.json delete mode 100644 packages/platform-server/__e2e__/graph.socket.gateway.e2e.test.ts delete mode 100644 packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts create mode 100644 packages/platform-server/__tests__/notifications.publisher.bus.test.ts delete mode 100644 packages/platform-server/__tests__/socket.events.test.ts delete mode 100644 packages/platform-server/__tests__/socket.gateway.test.ts delete mode 100644 packages/platform-server/__tests__/socket.node_status.integration.test.ts delete mode 100644 packages/platform-server/__tests__/socket.realtime.integration.test.ts delete mode 100644 packages/platform-server/src/gateway/gateway.module.ts delete mode 100644 packages/platform-server/src/gateway/graph.socket.gateway.ts create mode 100644 packages/platform-server/src/notifications/notifications.broker.ts create mode 100644 packages/platform-server/src/notifications/notifications.module.ts create mode 100644 packages/platform-server/src/notifications/notifications.publisher.ts create mode 100644 packages/platform-server/src/notifications/notifications.schemas.ts create mode 100644 packages/shared/src/notifications/index.ts diff --git a/README.md b/README.md index 31fbb2349..ac3e7577c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,7 @@ pnpm --filter @agyn/platform-server run prisma:generate ## Deployment - Local compose: docker-compose.yml includes all supporting services required for dev workflows. +- E2E ingress: docker-compose.e2e.yml builds the platform server, notifications gateway, Redis, and Envoy. See docs/runbooks/notifications-gateway.md for usage. - Server container: - Image: ghcr.io/agynio/platform-server - Required env: AGENTS_DATABASE_URL, LLM_PROVIDER, LITELLM_BASE_URL, LITELLM_MASTER_KEY, optional Vault and CORS diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 000000000..0288806e4 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,159 @@ +services: + agents-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${AGENTS_DB_USER:-agents} + POSTGRES_PASSWORD: ${AGENTS_DB_PASSWORD:-agents} + POSTGRES_DB: ${AGENTS_DB_NAME:-agents} + ports: + - "5443:5432" + volumes: + - agents_pgdata:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${AGENTS_DB_USER:-agents} -d ${AGENTS_DB_NAME:-agents}", + ] + interval: 10s + timeout: 5s + retries: 5 + networks: + - agents_net + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + networks: + - agents_net + + litellm-db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: litellm + POSTGRES_USER: litellm + POSTGRES_PASSWORD: change-me + volumes: + - litellm_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U litellm -d litellm"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - agents_net + + litellm: + image: ghcr.io/berriai/litellm:main-latest + restart: unless-stopped + environment: + DATABASE_URL: postgresql://litellm:change-me@litellm-db:5432/litellm + STORE_MODEL_IN_DB: "True" + UI_USERNAME: ${LITELLM_UI_USERNAME:-admin} + UI_PASSWORD: ${LITELLM_UI_PASSWORD:-admin} + PORT: "4000" + HOST: "0.0.0.0" + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-dev-master-1234} + LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:-sk-dev-salt-1234} + depends_on: + litellm-db: + condition: service_healthy + networks: + - agents_net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"] + interval: 15s + timeout: 3s + retries: 5 + start_period: 10s + + docker-runner: + build: + context: . + dockerfile: packages/docker-runner/Dockerfile + restart: unless-stopped + environment: + DOCKER_RUNNER_SHARED_SECRET: ${DOCKER_RUNNER_SHARED_SECRET:-dev-shared-secret} + DOCKER_RUNNER_PORT: ${DOCKER_RUNNER_PORT:-7071} + volumes: + - type: bind + source: /var/run/docker.sock + target: /var/run/docker.sock + networks: + - agents_net + + platform-server: + build: + context: . + dockerfile: packages/platform-server/Dockerfile + depends_on: + agents-db: + condition: service_healthy + redis: + condition: service_started + litellm: + condition: service_healthy + docker-runner: + condition: service_started + environment: + NODE_ENV: production + PORT: 3010 + AGENTS_DATABASE_URL: postgresql://${AGENTS_DB_USER:-agents}:${AGENTS_DB_PASSWORD:-agents}@agents-db:5432/${AGENTS_DB_NAME:-agents} + LLM_PROVIDER: litellm + LITELLM_BASE_URL: http://litellm:4000 + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-dev-master-1234} + DOCKER_RUNNER_BASE_URL: http://docker-runner:${DOCKER_RUNNER_PORT:-7071} + DOCKER_RUNNER_SHARED_SECRET: ${DOCKER_RUNNER_SHARED_SECRET:-dev-shared-secret} + NOTIFICATIONS_REDIS_URL: redis://redis:6379/0 + NOTIFICATIONS_CHANNEL: ${NOTIFICATIONS_CHANNEL:-notifications.v1} + WORKSPACE_NETWORK_NAME: agents_net + NCPS_ENABLED: "false" + GRAPH_REPO_PATH: /data/graph + volumes: + - ./data/graph:/data/graph + networks: + - agents_net + + notifications-gateway: + build: + context: . + dockerfile: packages/notifications-gateway/Dockerfile + depends_on: + redis: + condition: service_started + environment: + PORT: 3011 + HOST: 0.0.0.0 + REDIS_URL: redis://redis:6379/0 + NOTIFICATIONS_CHANNEL: ${NOTIFICATIONS_CHANNEL:-notifications.v1} + SOCKET_IO_PATH: /socket.io + networks: + - agents_net + + envoy: + image: envoyproxy/envoy:v1.31.2 + depends_on: + platform-server: + condition: service_started + notifications-gateway: + condition: service_started + ports: + - "8080:8080" + - "9901:9901" + volumes: + - ./ops/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro + command: ["envoy", "-c", "/etc/envoy/envoy.yaml"] + networks: + - agents_net + +volumes: + agents_pgdata: + litellm_pgdata: + +networks: + agents_net: + name: agents_net diff --git a/docs/runbooks/notifications-gateway.md b/docs/runbooks/notifications-gateway.md new file mode 100644 index 000000000..9c7bfee34 --- /dev/null +++ b/docs/runbooks/notifications-gateway.md @@ -0,0 +1,53 @@ +# Notifications gateway runbook + +This runbook explains how to boot the end-to-end stack that fronts the platform +server with Envoy and exposes the Socket.IO endpoint via the standalone +`notifications-gateway` service. + +## Components + +- **Redis** – transports notification envelopes from the platform server to the + Socket.IO gateway via the `notifications.v1` Pub/Sub channel. +- **Platform server** – publishes notifications to Redis and serves the REST + API on port `3010`. +- **Notifications gateway** – subscribes to Redis and re-broadcasts envelopes + to Socket.IO clients. +- **Envoy** – single ingress point that routes `/api/*` requests to the + platform server and `/socket.io/*` to the gateway, exposing port `8080`. +- Supporting services: Postgres (`agents-db`), LiteLLM (`litellm`/`litellm-db`), + and `docker-runner` for tool execution parity. + +## Prerequisites + +- Docker Engine 24+ +- Compose v2 (`docker compose version`) +- Ports `8080`, `9901`, `4000`, `5443`, and `6379` available on the host + +## Start the stack + +``` +docker compose -f docker-compose.e2e.yml up --build +``` + +The first build can take several minutes because both the platform server and +notifications gateway images are constructed from the local workspace. Once the +containers are healthy you can hit the stack via Envoy: + +``` +curl -s http://localhost:8080/api/health | jq +``` + +The Socket.IO endpoint is exposed on the same origin under `/socket.io`. Any UI +or client library that previously connected to the in-process gateway can now +point to `http://localhost:8080` and reuse the existing configuration. + +## Shutdown and cleanup + +Press `Ctrl+C` to stop the stack, then remove containers and volumes with: + +``` +docker compose -f docker-compose.e2e.yml down -v +``` + +This tears down Postgres/LiteLLM volumes so the next run starts from a clean +state. diff --git a/ops/envoy/envoy.yaml b/ops/envoy/envoy.yaml new file mode 100644 index 000000000..71873f4f5 --- /dev/null +++ b/ops/envoy/envoy.yaml @@ -0,0 +1,72 @@ +static_resources: + listeners: + - name: ingress_http + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + use_remote_address: true + upgrade_configs: + - upgrade_type: websocket + route_config: + name: local_route + virtual_hosts: + - name: platform + domains: + - "*" + routes: + - match: + prefix: "/socket.io" + route: + cluster: notifications_gateway + timeout: 0s + - match: + prefix: "/api" + route: + cluster: platform_server + timeout: 30s + - match: + prefix: "/" + route: + cluster: platform_server + http_filters: + - name: envoy.filters.http.router + clusters: + - name: platform_server + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: platform_server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: platform-server + port_value: 3010 + - name: notifications_gateway + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: notifications_gateway + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: notifications-gateway + port_value: 3011 +admin: + access_log_path: /tmp/admin-access.log + address: + socket_address: + address: 0.0.0.0 + port_value: 9901 diff --git a/packages/notifications-gateway/Dockerfile b/packages/notifications-gateway/Dockerfile new file mode 100644 index 000000000..9d25349ad --- /dev/null +++ b/packages/notifications-gateway/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:20-slim AS base + +ENV PNPM_HOME=/pnpm \ + PNPM_STORE_PATH=/pnpm-store \ + PATH=/pnpm:$PATH + +RUN corepack enable \ + && corepack prepare pnpm@10.5.0 --activate + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git openssl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ + +RUN pnpm fetch + +FROM base AS build + +COPY . . + +RUN pnpm install --filter @agyn/notifications-gateway... --offline --frozen-lockfile + +RUN pnpm --filter @agyn/notifications-gateway run build + +RUN pnpm deploy --filter @agyn/notifications-gateway --prod --legacy /opt/app + +FROM node:20-slim AS runtime + +ENV NODE_ENV=production \ + PORT=3011 + +WORKDIR /opt/app/packages/notifications-gateway + +RUN corepack enable \ + && corepack prepare pnpm@10.5.0 --activate \ + && apt-get update \ + && apt-get install -y --no-install-recommends git openssl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build --chown=node:node /opt/app /opt/app + +EXPOSE 3011 + +USER node + +CMD ["node", "dist/index.js"] diff --git a/packages/notifications-gateway/README.md b/packages/notifications-gateway/README.md new file mode 100644 index 000000000..2f86883df --- /dev/null +++ b/packages/notifications-gateway/README.md @@ -0,0 +1,26 @@ +# Notifications Gateway + +The notifications gateway exposes the Socket.IO endpoint consumed by the UI. It subscribes to the +`notifications.v1` Redis Pub/Sub channel and forwards validated events to the appropriate rooms. The +gateway keeps the legacy room model and subscribe validation identical to the previous in-process +Socket.IO server. + +## Environment variables + +| Variable | Description | Default | +| --- | --- | --- | +| `PORT` | TCP port for the HTTP/WebSocket server | `3011` | +| `HOST` | Bind address | `0.0.0.0` | +| `SOCKET_IO_PATH` | Socket.IO path (must remain `/socket.io` for the UI) | `/socket.io` | +| `REDIS_URL` | Redis connection string (e.g. `redis://redis:6379/0`) | _required_ | +| `NOTIFICATIONS_CHANNEL` | Pub/Sub channel name | `notifications.v1` | +| `LOG_LEVEL` | Pino log level (`fatal`..`trace`) | `info` | + +## Development + +```bash +pnpm --filter @agyn/notifications-gateway dev +``` + +The development command runs the gateway via `tsx` with hot reload. For production builds, run +`pnpm --filter @agyn/notifications-gateway build` and execute `node dist/index.js`. diff --git a/packages/notifications-gateway/package.json b/packages/notifications-gateway/package.json new file mode 100644 index 000000000..98afdc30b --- /dev/null +++ b/packages/notifications-gateway/package.json @@ -0,0 +1,24 @@ +{ + "name": "@agyn/notifications-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch --clear-screen=false src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js" + }, + "dependencies": { + "@agyn/shared": "workspace:*", + "ioredis": "^5.4.1", + "pino": "^10.1.0", + "socket.io": "^4.8.1", + "zod": "^4.1.9" + }, + "devDependencies": { + "@types/node": "^24.5.1", + "tsx": "^4.20.5", + "typescript": "^5.8.3" + } +} diff --git a/packages/notifications-gateway/src/config.ts b/packages/notifications-gateway/src/config.ts new file mode 100644 index 000000000..6d6af9eb7 --- /dev/null +++ b/packages/notifications-gateway/src/config.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { NOTIFICATIONS_CHANNEL } from '@agyn/shared'; + +const configSchema = z.object({ + port: z + .union([z.string(), z.number()]) + .default(3011) + .transform((value) => { + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { + throw new Error('PORT must be a valid TCP port'); + } + return parsed; + }), + host: z.string().default('0.0.0.0'), + socketPath: z + .string() + .default('/socket.io') + .transform((value) => (value.startsWith('/') ? value : `/${value}`)), + redisUrl: z + .string() + .min(1, 'REDIS_URL is required') + .refine((value) => value.startsWith('redis://') || value.startsWith('rediss://'), { + message: 'REDIS_URL must start with redis:// or rediss://', + }), + redisChannel: z.string().min(1).default(NOTIFICATIONS_CHANNEL), + logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), +}); + +export type GatewayConfig = z.infer; + +export function loadConfig(): GatewayConfig { + return configSchema.parse({ + port: process.env.PORT, + host: process.env.HOST, + socketPath: process.env.SOCKET_IO_PATH, + redisUrl: process.env.REDIS_URL, + redisChannel: process.env.NOTIFICATIONS_CHANNEL, + logLevel: process.env.LOG_LEVEL, + }); +} diff --git a/packages/notifications-gateway/src/index.ts b/packages/notifications-gateway/src/index.ts new file mode 100644 index 000000000..4eac56e74 --- /dev/null +++ b/packages/notifications-gateway/src/index.ts @@ -0,0 +1,76 @@ +import { createServer } from 'node:http'; +import process from 'node:process'; +import { loadConfig } from './config'; +import { createLogger } from './logger'; +import { createSocketServer } from './socket/server'; +import { NotificationsSubscriber } from './redis/notifications-subscriber'; +import type { NotificationEnvelope } from '@agyn/shared'; +import type { Logger } from './logger'; +import type { Server as SocketIOServer } from 'socket.io'; + +async function main(): Promise { + const config = loadConfig(); + const logger = createLogger(config.logLevel); + + const httpServer = createServer(); + const io = createSocketServer({ server: httpServer, path: config.socketPath, logger }); + const subscriber = new NotificationsSubscriber( + { url: config.redisUrl, channel: config.redisChannel }, + logger, + ); + + subscriber.on('notification', (envelope: NotificationEnvelope) => dispatchToRooms(io, envelope, logger)); + subscriber.on('error', (error: Error) => { + logger.error({ error: serializeError(error) }, 'redis subscriber emitted error'); + }); + + await subscriber.start(); + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen({ port: config.port, host: config.host }, () => { + logger.info({ port: config.port, host: config.host, path: config.socketPath }, 'gateway listening'); + httpServer.off('error', reject); + resolve(); + }); + }); + + const shutdown = async (signal: NodeJS.Signals) => { + logger.info({ signal }, 'shutting down notifications gateway'); + httpServer.close(); + await subscriber.stop(); + process.exit(0); + }; + + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); +} + +const dispatchToRooms = (io: SocketIOServer, envelope: NotificationEnvelope, logger: Logger) => { + for (const room of envelope.rooms) { + try { + io.to(room).emit(envelope.event, envelope.payload); + } catch (error) { + logger.warn({ room, event: envelope.event, error: serializeError(error) }, 'emit failed'); + } + } +}; + +const serializeError = (error: unknown): { name?: string; message: string } => { + if (error instanceof Error) return { name: error.name, message: error.message }; + if (typeof error === 'object') { + try { + return { message: JSON.stringify(error) }; + } catch { + return { message: '[object]' }; + } + } + return { message: String(error) }; +}; + +void main().catch((error) => { + const serialized = serializeError(error); + // eslint-disable-next-line no-console -- fallback for bootstrap errors + console.error('notifications-gateway failed to start', serialized); + process.exit(1); +}); diff --git a/packages/notifications-gateway/src/logger.ts b/packages/notifications-gateway/src/logger.ts new file mode 100644 index 000000000..e5c7bc7ac --- /dev/null +++ b/packages/notifications-gateway/src/logger.ts @@ -0,0 +1,10 @@ +import pino from 'pino'; + +export type Logger = pino.Logger; + +export const createLogger = (level: string): Logger => + pino({ + level, + base: undefined, + timestamp: pino.stdTimeFunctions.isoTime, + }); diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.ts new file mode 100644 index 000000000..7fcc235cc --- /dev/null +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.ts @@ -0,0 +1,74 @@ +import { EventEmitter } from 'node:events'; +import Redis from 'ioredis'; +import type { Logger } from '../logger'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { NotificationEnvelopeSchema } from './schema'; + +export class NotificationsSubscriber extends EventEmitter { + private redis: Redis | null = null; + + constructor( + private readonly options: { url: string; channel: string }, + private readonly logger: Logger, + ) { + super(); + } + + async start(): Promise { + if (this.redis) return; + this.redis = new Redis(this.options.url, { + lazyConnect: true, + maxRetriesPerRequest: null, + enableReadyCheck: true, + autoResubscribe: true, + }); + this.redis.on('error', (error) => { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error({ error: serializeError(err) }, 'redis subscriber error'); + this.emit('error', err); + }); + this.redis.on('ready', () => { + this.logger.info('redis subscriber ready'); + this.emit('ready'); + }); + await this.redis.connect(); + await this.redis.subscribe(this.options.channel, (err) => { + if (err) throw err; + this.logger.info({ channel: this.options.channel }, 'subscribed to notifications channel'); + }); + this.redis.on('message', (channel, message) => { + if (channel !== this.options.channel) return; + this.handleMessage(message); + }); + } + + async stop(): Promise { + if (!this.redis) return; + const current = this.redis; + this.redis = null; + current.removeAllListeners('message'); + await current.quit(); + } + + private handleMessage(raw: string): void { + try { + const parsed = JSON.parse(raw); + const notification = NotificationEnvelopeSchema.parse(parsed); + this.emit('notification', notification); + } catch (error) { + this.logger.warn({ error: serializeError(error), raw }, 'failed to parse notification'); + } + } +} + +const serializeError = (error: unknown): { name?: string; message: string } => { + if (error instanceof Error) return { name: error.name, message: error.message }; + if (typeof error === 'object') { + try { + return { message: JSON.stringify(error) }; + } catch { + return { message: '[object]' }; + } + } + return { message: String(error) }; +}; diff --git a/packages/notifications-gateway/src/redis/schema.ts b/packages/notifications-gateway/src/redis/schema.ts new file mode 100644 index 000000000..566cf8a48 --- /dev/null +++ b/packages/notifications-gateway/src/redis/schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { RoomSchema } from '../rooms'; + +export const NotificationEnvelopeSchema = z + .object({ + id: z.string().min(1), + ts: z.string().datetime(), + source: z.literal('platform-server'), + rooms: z.array(RoomSchema).min(1), + event: z.string().min(1), + payload: z.unknown(), + }) + .strict() satisfies z.ZodType; diff --git a/packages/notifications-gateway/src/rooms.ts b/packages/notifications-gateway/src/rooms.ts new file mode 100644 index 000000000..2a5735c95 --- /dev/null +++ b/packages/notifications-gateway/src/rooms.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const RoomSchema = z.union([ + z.literal('graph'), + z.literal('threads'), + z.string().regex(/^thread:[0-9a-z-]{1,64}$/i), + z.string().regex(/^run:[0-9a-z-]{1,64}$/i), + z.string().regex(/^node:[0-9a-z-]{1,64}$/i), +]); + +export type ValidRoom = z.infer; diff --git a/packages/notifications-gateway/src/socket/server.ts b/packages/notifications-gateway/src/socket/server.ts new file mode 100644 index 000000000..a3573eb23 --- /dev/null +++ b/packages/notifications-gateway/src/socket/server.ts @@ -0,0 +1,24 @@ +import type { Server as HTTPServer } from 'node:http'; +import { Server as SocketIOServer, type ServerOptions } from 'socket.io'; +import type { Logger } from '../logger'; +import { attachSubscribeHandler } from './subscriptions'; + +export const createSocketServer = (params: { + server: HTTPServer; + path: string; + logger: Logger; +}): SocketIOServer => { + const options: Partial = { + path: params.path, + transports: ['websocket'], + cors: { origin: '*' }, + serveClient: false, + allowRequest: (_req, callback) => callback(null, true), + }; + const io = new SocketIOServer(params.server, options); + io.on('connection', (socket) => { + params.logger.info({ socketId: socket.id }, 'socket connected'); + attachSubscribeHandler(socket, params.logger); + }); + return io; +}; diff --git a/packages/notifications-gateway/src/socket/subscriptions.ts b/packages/notifications-gateway/src/socket/subscriptions.ts new file mode 100644 index 000000000..e29633dc8 --- /dev/null +++ b/packages/notifications-gateway/src/socket/subscriptions.ts @@ -0,0 +1,56 @@ +import type { Socket } from 'socket.io'; +import { z } from 'zod'; +import type { Logger } from '../logger'; +import { RoomSchema, type ValidRoom } from '../rooms'; + +const SubscribeSchema = z + .object({ + rooms: z.array(RoomSchema).optional(), + room: RoomSchema.optional(), + }) + .strict(); + +type SubscribePayload = z.infer; + +export function attachSubscribeHandler(socket: Socket, logger: Logger): void { + socket.on('subscribe', (payload: unknown, ack?: (response: unknown) => void) => { + const parsed = SubscribeSchema.safeParse(payload); + if (!parsed.success) { + const issues = parsed.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + code: issue.code, + })); + logger.warn({ socketId: socket.id, issues }, 'subscribe payload invalid'); + if (typeof ack === 'function') ack({ ok: false, error: 'invalid_payload', issues }); + return; + } + const rooms = collectRooms(parsed.data); + for (const room of rooms) socket.join(room); + if (typeof ack === 'function') ack({ ok: true, rooms }); + }); + + socket.on('error', (error: unknown) => { + logger.warn({ socketId: socket.id, error: serializeError(error) }, 'socket error'); + }); +} + +const collectRooms = (payload: SubscribePayload): ValidRoom[] => { + if (payload.rooms && payload.rooms.length > 0) return payload.rooms; + if (payload.room) return [payload.room]; + return []; +}; + +const serializeError = (error: unknown): { name?: string; message: string } => { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + if (typeof error === 'object') { + try { + return { message: JSON.stringify(error) }; + } catch { + return { message: '[object]' }; + } + } + return { message: String(error) }; +}; diff --git a/packages/notifications-gateway/tsconfig.json b/packages/notifications-gateway/tsconfig.json new file mode 100644 index 000000000..1c622efd7 --- /dev/null +++ b/packages/notifications-gateway/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@agyn/shared": ["../shared/src/index.ts"], + "@agyn/shared/*": ["../shared/src/*"] + } + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/platform-server/.env.example b/packages/platform-server/.env.example index eaa936249..16a5d15ed 100644 --- a/packages/platform-server/.env.example +++ b/packages/platform-server/.env.example @@ -48,6 +48,10 @@ DOCKER_RUNNER_SHARED_SECRET=dev-shared-secret # Optional request timeout override (ms) # DOCKER_RUNNER_TIMEOUT_MS=30000 +# Redis Pub/Sub settings for notifications +NOTIFICATIONS_REDIS_URL=redis://localhost:6379/0 +# NOTIFICATIONS_CHANNEL=notifications.v1 + # Nix cache proxy (ncps) endpoints # NCPS_URL_SERVER=http://localhost:8501 # NCPS_URL_CONTAINER=http://ncps:8501 diff --git a/packages/platform-server/README.md b/packages/platform-server/README.md index 9a24f5c34..2a7728ecd 100644 --- a/packages/platform-server/README.md +++ b/packages/platform-server/README.md @@ -28,7 +28,14 @@ Graph persistence - `VOLUME_GC_MAX_PER_SWEEP` (default `100`) - `VOLUME_GC_CONCURRENCY` (default `3`) - `VOLUME_GC_COOLDOWN_MS` (default `600000`) -- + +## Notifications gateway bridge + +- The platform server no longer hosts a socket.io server directly. Instead it publishes every notification over Redis so the dedicated `notifications-gateway` service can rebroadcast the same payloads to clients. +- Configure the bridge via env: + - `NOTIFICATIONS_REDIS_URL` (required) — Redis connection string used for Pub/Sub. + - `NOTIFICATIONS_CHANNEL` (optional, default `notifications.v1`) — channel consumed by both publisher and gateway. + ## MCP environment configuration Local MCP server nodes accept an environment overlay via the `env` array in node config. Each entry includes a `name` and a `value`, where `value` may be a literal string or a reference resolved at runtime. diff --git a/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts index 113e5c99f..6ec329a84 100644 --- a/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts +++ b/packages/platform-server/__e2e__/app.bootstrap.smoke.test.ts @@ -17,7 +17,8 @@ import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; import { VaultService } from '../src/vault/vault.service'; import { AgentNode } from '../src/nodes/agent/agent.node'; import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; +import { NotificationsBroker } from '../src/notifications/notifications.broker'; import { GraphRepository } from '../src/graph/graph.repository'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; @@ -161,18 +162,28 @@ describe('App bootstrap smoke test', () => { } const llmProvisionerStub = new StubProvisioner(); - const subscriptionSpy = vi.spyOn(EventsBusService.prototype, 'subscribeToRunEvents'); - const configService = new ConfigService().init( configSchema.parse({ llmProvider: process.env.LLM_PROVIDER || 'litellm', litellmBaseUrl: process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000', litellmMasterKey: process.env.LITELLM_MASTER_KEY || 'sk-dev-master-1234', agentsDatabaseUrl: process.env.AGENTS_DATABASE_URL || 'postgres://localhost:5432/test', + notificationsRedisUrl: 'redis://localhost:6379/0', + notificationsChannel: 'notifications.v1', ...runnerConfigDefaults, }), ); + const notificationsPublisherStub = { + onModuleInit: vi.fn(), + onModuleDestroy: vi.fn(), + } satisfies Partial; + const notificationsBrokerStub = { + connect: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } satisfies Partial; + const moduleBuilder = Test.createTestingModule({ imports: [AppModule], providers: [ @@ -212,7 +223,11 @@ describe('App bootstrap smoke test', () => { .overrideProvider(LLMProvisioner) .useValue(llmProvisionerStub) .overrideProvider(LLMSettingsService) - .useValue({}); + .useValue({}) + .overrideProvider(NotificationsBroker) + .useValue(notificationsBrokerStub) + .overrideProvider(NotificationsPublisher) + .useValue(notificationsPublisherStub as NotificationsPublisher); const moduleRef = await moduleBuilder.compile(); expect(moduleRef.get(ConfigService)).toBe(configService); @@ -241,17 +256,9 @@ describe('App bootstrap smoke test', () => { expect(agentProbe.agent).toBeInstanceOf(AgentNode); expect(Reflect.get(agentProbe.agent as object, 'llmProvisioner')).toBe(llmProvisioner); - const gateway = app.get(GraphSocketGateway); - expect(gateway).toBeInstanceOf(GraphSocketGateway); - expect(subscriptionSpy).toHaveBeenCalledTimes(1); - const [listener] = subscriptionSpy.mock.calls[0] ?? []; - expect(typeof listener).toBe('function'); - - const cleanupRegistry = Reflect.get(gateway as object, 'cleanup') as unknown; - expect(Array.isArray(cleanupRegistry)).toBe(true); - expect((cleanupRegistry as unknown[]).length).toBeGreaterThan(0); + const notifications = app.get(NotificationsPublisher); + expect(notifications).toBe(notificationsPublisherStub); } finally { - subscriptionSpy.mockRestore(); await app.close(); await moduleRef.close(); } diff --git a/packages/platform-server/__e2e__/graph.socket.gateway.e2e.test.ts b/packages/platform-server/__e2e__/graph.socket.gateway.e2e.test.ts deleted file mode 100644 index ff5ea278a..000000000 --- a/packages/platform-server/__e2e__/graph.socket.gateway.e2e.test.ts +++ /dev/null @@ -1,686 +0,0 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -import { Test } from '@nestjs/testing'; -import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; -import type { FastifyInstance } from 'fastify'; -import type { AddressInfo } from 'net'; -import { PassThrough } from 'node:stream'; -import { io as createClient, type Socket } from 'socket.io-client'; -import WebSocket, { type RawData } from 'ws'; - -import type { MessageKind, RunStatus } from '@prisma/client'; -import { EventsBusService } from '../src/events/events-bus.service'; -import { RunEventsService } from '../src/events/run-events.service'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; -import { PrismaService } from '../src/core/services/prisma.service'; -import { ContainerTerminalGateway } from '../src/infra/container/terminal.gateway'; -import { TerminalSessionsService, type TerminalSessionRecord } from '../src/infra/container/terminal.sessions.service'; -import { - WorkspaceProvider, - type WorkspaceKey, - type WorkspaceSpec, - type ExecRequest, - type ExecResult, - type DestroyWorkspaceOptions, -} from '../src/workspace/providers/workspace.provider'; -import type { - WorkspaceTerminalSession, - WorkspaceTerminalSessionRequest, - WorkspaceStdioSession, - WorkspaceStdioSessionRequest, - WorkspaceLogsRequest, - WorkspaceLogsSession, -} from '../src/workspace/runtime/workspace.runtime.provider'; - -class LiveGraphRuntimeStub { - subscribe() { - return () => undefined; - } -} - -class ThreadsMetricsServiceStub { - async getThreadsMetrics(): Promise> { - return {}; - } -} - -class PrismaServiceStub { - private readonly runEvents = new Map(); - - setRunEvent(event: { id: string }): void { - this.runEvents.set(event.id, event); - } - - clear(): void { - this.runEvents.clear(); - } - - getClient() { - return { - $queryRaw: async () => [], - runEvent: { - findUnique: async ({ where }: { where: { id: string } }) => { - const id = where?.id; - if (!id) return null; - const stored = this.runEvents.get(id); - return stored ?? null; - }, - }, - }; - } -} - -class WorkspaceProviderStub extends WorkspaceProvider { - capabilities() { - return { - persistentVolume: true, - network: true, - networkAliases: true, - dockerInDocker: true, - stdioSession: true, - terminalSession: true, - logsSession: true, - } as const; - } - - async ensureWorkspace(_key: WorkspaceKey, _spec: WorkspaceSpec) { - return { workspaceId: 'stub-workspace', created: false, providerType: 'docker', status: 'running' as const }; - } - - async exec(_workspaceId: string, _request: ExecRequest): Promise { - return { stdout: '', stderr: '', exitCode: 0 }; - } - - async openStdioSession( - _workspaceId: string, - _request: WorkspaceStdioSessionRequest, - ): Promise { - const stdin = new PassThrough(); - const stdout = new PassThrough(); - const stderr = new PassThrough(); - setTimeout(() => { - stdout.write('ready\n'); - }, 50); - return { - stdin, - stdout, - stderr, - close: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - }; - } - - async openTerminalSession( - _workspaceId: string, - _request: WorkspaceTerminalSessionRequest, - ): Promise { - const stdin = new PassThrough(); - const stdout = new PassThrough(); - const stderr = new PassThrough(); - const execId = 'exec-stub'; - setTimeout(() => { - stdout.write('ready\n'); - }, 50); - return { - sessionId: execId, - execId, - stdin, - stdout, - stderr, - resize: async () => undefined, - close: async () => ({ exitCode: 0, stdout: '', stderr: '' }), - }; - } - - async openLogsSession( - _workspaceId: string, - _request: WorkspaceLogsRequest, - ): Promise { - const stream = new PassThrough(); - setImmediate(() => stream.end()); - return { stream, close: async () => { stream.end(); } }; - } - - async destroyWorkspace(_workspaceId: string, _options?: DestroyWorkspaceOptions): Promise { - return; - } - - async touchWorkspace(_workspaceId: string): Promise { - return; - } - - async putArchive(): Promise { - return; - } -} - -class TerminalSessionsServiceStub { - public connected = false; - public closed = false; - public validations = 0; - public connects = 0; - public readonly session: TerminalSessionRecord; - - constructor() { - const now = Date.now(); - this.session = { - sessionId: '11111111-1111-4111-8111-111111111111', - token: 'stub-token', - workspaceId: '22222222-2222-4222-8222-222222222222', - shell: '/bin/sh', - cols: 80, - rows: 24, - createdAt: now, - lastActivityAt: now, - idleTimeoutMs: 10 * 60 * 1000, - maxDurationMs: 60 * 60 * 1000, - state: 'pending', - }; - } - - reset(): void { - this.connected = false; - this.closed = false; - this.validations = 0; - this.connects = 0; - this.session.state = 'pending'; - this.session.lastActivityAt = Date.now(); - } - - validate(sessionId: string, token: string): TerminalSessionRecord { - this.validations += 1; - if (sessionId !== this.session.sessionId) throw new Error('session_not_found'); - if (token !== this.session.token) throw new Error('invalid_token'); - this.session.lastActivityAt = Date.now(); - return this.session; - } - - markConnected(sessionId: string): void { - if (sessionId !== this.session.sessionId) throw new Error('session_not_found'); - if (this.session.state === 'connected') throw new Error('session_already_connected'); - this.session.state = 'connected'; - this.session.lastActivityAt = Date.now(); - this.connected = true; - this.connects += 1; - } - - get(sessionId: string): TerminalSessionRecord | undefined { - return sessionId === this.session.sessionId ? this.session : undefined; - } - - touch(sessionId: string): void { - if (sessionId === this.session.sessionId) { - this.session.lastActivityAt = Date.now(); - } - } - - close(sessionId: string): void { - if (sessionId === this.session.sessionId) { - this.closed = true; - } - } -} - -type RunEventRecordStub = { - id: string; - runId: string; - threadId: string; - type: string; - status: string; - ts: Date; - startedAt: Date | null; - endedAt: Date | null; - durationMs: number | null; - nodeId: string | null; - sourceKind: string; - sourceSpanId: string | null; - metadata: unknown; - errorCode: string | null; - errorMessage: string | null; - llmCall: unknown; - toolExecution: unknown; - summarization: unknown; - injection: unknown; - eventMessage: unknown; - attachments: unknown[]; -}; - -const createRunEventRecord = (overrides: Partial = {}): RunEventRecordStub => { - const now = new Date(); - return { - id: 'evt-stub', - runId: 'run-stub', - threadId: 'thread-stub', - type: 'tool_execution', - status: 'running', - ts: now, - startedAt: now, - endedAt: null, - durationMs: null, - nodeId: 'node-1', - sourceKind: 'internal', - sourceSpanId: null, - metadata: {}, - errorCode: null, - errorMessage: null, - llmCall: null, - toolExecution: null, - summarization: null, - injection: null, - eventMessage: null, - attachments: [], - ...overrides, - }; -}; - -const waitForDisconnect = (socket: Socket): Promise => - new Promise((resolve) => { - if (!socket.connected) { - resolve(); - return; - } - socket.once('disconnect', () => resolve()); - socket.disconnect(); - }); - -const waitForWsClose = (socket: WebSocket): Promise => - new Promise((resolve) => { - if (socket.readyState === WebSocket.CLOSED) { - resolve(); - return; - } - socket.once('close', () => resolve()); - socket.close(); - }); - -const rawDataToString = (raw: RawData): string => { - if (typeof raw === 'string') return raw; - if (Buffer.isBuffer(raw)) return raw.toString('utf8'); - if (Array.isArray(raw)) return Buffer.concat(raw).toString('utf8'); - if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8'); - return ''; -}; - -describe('Socket gateway real server handshakes', () => { - let app: NestFastifyApplication; - let fastify: FastifyInstance; - let baseUrl: string; - let terminalSessions: TerminalSessionsServiceStub; - let graphGateway: GraphSocketGateway; - let eventsBusService: EventsBusService; - let prismaStub: PrismaServiceStub; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [ - GraphSocketGateway, - { provide: LiveGraphRuntime, useClass: LiveGraphRuntimeStub }, - { provide: ThreadsMetricsService, useClass: ThreadsMetricsServiceStub }, - { provide: PrismaService, useClass: PrismaServiceStub }, - ContainerTerminalGateway, - { provide: TerminalSessionsService, useClass: TerminalSessionsServiceStub }, - { provide: WorkspaceProvider, useClass: WorkspaceProviderStub }, - EventsBusService, - RunEventsService, - ], - }).compile(); - - app = moduleRef.createNestApplication(new FastifyAdapter()); - await app.init(); - - fastify = app.getHttpAdapter().getInstance(); - terminalSessions = app.get(TerminalSessionsService) as unknown as TerminalSessionsServiceStub; - prismaStub = app.get(PrismaService) as unknown as PrismaServiceStub; - eventsBusService = app.get(EventsBusService); - - const terminalGateway = app.get(ContainerTerminalGateway); - terminalGateway.registerRoutes(fastify); - - graphGateway = app.get(GraphSocketGateway); - graphGateway.init({ server: fastify.server }); - - await app.listen(0, '127.0.0.1'); - - const addressInfo = fastify.server.address() as AddressInfo; - if (!addressInfo || typeof addressInfo.port !== 'number') { - throw new Error('Failed to determine Fastify listen port'); - } - baseUrl = `http://127.0.0.1:${addressInfo.port}`; - }); - - afterAll(async () => { - await app.close(); - }); - - beforeEach(() => { - terminalSessions.reset(); - prismaStub.clear(); - }); - - it('attaches socket.io, subscribes, and receives graph events', async () => { - let upgradeCount = 0; - const upgradeListener = () => { - upgradeCount += 1; - }; - fastify.server.on('upgrade', upgradeListener); - let client: Socket | null = null; - try { - client = createClient(baseUrl, { - path: '/socket.io', - transports: ['websocket'], - reconnection: false, - }); - - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - client?.removeAllListeners('connect'); - client?.removeAllListeners('connect_error'); - reject(new Error('Timed out waiting for socket connect')); - }, 3000); - client?.once('connect', () => { - clearTimeout(timer); - resolve(); - }); - client?.once('connect_error', (err) => { - clearTimeout(timer); - reject(err); - }); - }); - - const threadId = 'thread-123'; - const runId = 'run-456'; - - const messagePromise = new Promise>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for message_created event')); - }, 3000); - client?.once('message_created', (payload: Record) => { - clearTimeout(timer); - resolve(payload); - }); - }); - - const statusPromise = new Promise>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for run_status_changed event')); - }, 3000); - client?.once('run_status_changed', (payload: Record) => { - clearTimeout(timer); - resolve(payload); - }); - }); - - const runEventAppendedPromise = new Promise>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for run_event_appended event')); - }, 3000); - client?.once('run_event_appended', (payload: Record) => { - clearTimeout(timer); - resolve(payload); - }); - }); - - const runEventUpdatedPromise = new Promise>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for run_event_updated event')); - }, 3000); - client?.once('run_event_updated', (payload: Record) => { - clearTimeout(timer); - resolve(payload); - }); - }); - - const ack = await new Promise<{ ok: boolean; rooms?: string[]; error?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for subscribe ack')); - }, 3000); - client?.emit( - 'subscribe', - { rooms: ['threads', `thread:${threadId}`, `run:${runId}`] }, - (response: { ok: boolean; rooms?: string[]; error?: string }) => { - clearTimeout(timer); - resolve(response); - }, - ); - }); - - expect(ack.ok).toBe(true); - expect(ack.rooms).toEqual(expect.arrayContaining(['threads', `thread:${threadId}`, `run:${runId}`])); - - const createdAt = new Date(); - graphGateway.emitMessageCreated(threadId, { - id: 'msg-1', - kind: 'assistant' as MessageKind, - text: 'hello world', - source: { role: 'assistant' }, - createdAt, - runId, - }); - - graphGateway.emitRunStatusChanged(threadId, { - id: runId, - status: 'running' as RunStatus, - createdAt, - updatedAt: createdAt, - }); - - const runEventId = 'evt-1'; - const appendRecord = createRunEventRecord({ - id: runEventId, - runId, - threadId, - status: 'running', - ts: createdAt, - startedAt: createdAt, - }); - prismaStub.setRunEvent(appendRecord); - const publishAppendResult = await eventsBusService.publishEvent(runEventId, 'append'); - expect(publishAppendResult).not.toBeNull(); - - const [messagePayload, statusPayload, appendedPayload] = await Promise.all([messagePromise, statusPromise, runEventAppendedPromise]); - expect(messagePayload).toMatchObject({ threadId, message: expect.any(Object) }); - expect(statusPayload).toMatchObject({ threadId, run: expect.objectContaining({ id: runId }) }); - expect(appendedPayload).toMatchObject({ runId, mutation: 'append' }); - - const updatedAt = new Date(createdAt.getTime() + 1000); - const updateRecord = createRunEventRecord({ - id: runEventId, - runId, - threadId, - status: 'success', - ts: updatedAt, - startedAt: createdAt, - endedAt: updatedAt, - }); - prismaStub.setRunEvent(updateRecord); - const publishUpdateResult = await eventsBusService.publishEvent(runEventId, 'update'); - expect(publishUpdateResult).not.toBeNull(); - const updatedPayload = await runEventUpdatedPromise; - expect(updatedPayload).toMatchObject({ runId, mutation: 'update' }); - expect(upgradeCount).toBeGreaterThanOrEqual(1); - } finally { - if (client) { - await waitForDisconnect(client); - } - fastify.server.off('upgrade', upgradeListener); - } - }); - - it('handles container terminal websocket upgrades', async () => { - let upgradeCount = 0; - const upgradeListener = () => { - upgradeCount += 1; - }; - fastify.server.on('upgrade', upgradeListener); - const session = terminalSessions.session; - const wsUrl = new URL(baseUrl); - wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl.pathname = `/api/containers/${session.workspaceId}/terminal/ws`; - wsUrl.search = new URLSearchParams({ sessionId: session.sessionId, token: session.token }).toString(); - - const client = new WebSocket(wsUrl.toString()); - try { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - client.removeAllListeners('open'); - client.removeAllListeners('error'); - reject(new Error('Timed out waiting for terminal websocket open')); - }, 3000); - client.once('open', () => { - clearTimeout(timer); - resolve(); - }); - client.once('error', (err) => { - clearTimeout(timer); - reject(err); - }); - }); - - expect(client.readyState).toBe(WebSocket.OPEN); - - const message = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for terminal websocket message')); - }, 3000); - client.once('message', (data) => { - clearTimeout(timer); - resolve(rawDataToString(data as RawData)); - }); - client.once('error', (err) => { - clearTimeout(timer); - reject(err); - }); - }); - - const payload = JSON.parse(message) as { type?: string }; - expect(client.readyState).toBe(WebSocket.OPEN); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(client.readyState).toBe(WebSocket.OPEN); - expect(payload.type).toBeDefined(); - expect(upgradeCount).toBeGreaterThanOrEqual(1); - expect(terminalSessions.connected).toBe(true); - expect(terminalSessions.validations).toBeGreaterThan(0); - expect(terminalSessions.connects).toBeGreaterThan(0); - } finally { - await waitForWsClose(client); - fastify.server.off('upgrade', upgradeListener); - } - }); - - it('supports concurrent graph and terminal connections with event flow', async () => { - let upgradeCount = 0; - const upgradeListener = () => { - upgradeCount += 1; - }; - fastify.server.on('upgrade', upgradeListener); - - const session = terminalSessions.session; - const wsUrl = new URL(baseUrl); - wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl.pathname = `/api/containers/${session.workspaceId}/terminal/ws`; - wsUrl.search = new URLSearchParams({ sessionId: session.sessionId, token: session.token }).toString(); - - const client = createClient(baseUrl, { - path: '/socket.io', - transports: ['websocket'], - reconnection: false, - }); - - const terminalClient = new WebSocket(wsUrl.toString()); - - try { - await Promise.all([ - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for socket connect')); - }, 3000); - client.once('connect', () => { - clearTimeout(timer); - resolve(); - }); - client.once('connect_error', (err) => { - clearTimeout(timer); - reject(err); - }); - }), - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error('Timed out waiting for terminal websocket open')); - }, 3000); - terminalClient.once('open', () => { - clearTimeout(timer); - resolve(); - }); - terminalClient.once('error', (err) => { - clearTimeout(timer); - reject(err); - }); - }), - ]); - - const threadId = 'thread-999'; - const runId = 'run-999'; - - const subscribeAck = await new Promise<{ ok: boolean; rooms?: string[]; error?: string }>((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for subscribe ack')), 3000); - client.emit( - 'subscribe', - { rooms: ['threads', `thread:${threadId}`, `run:${runId}`] }, - (response: { ok: boolean; rooms?: string[]; error?: string }) => { - clearTimeout(timer); - resolve(response); - }, - ); - }); - - expect(subscribeAck.ok).toBe(true); - expect(terminalClient.readyState).toBe(WebSocket.OPEN); - - const runEventReceived = new Promise>((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for run_event_appended event')), 3000); - client.once('run_event_appended', (payload: Record) => { - clearTimeout(timer); - resolve(payload); - }); - }); - - const terminalMessage = new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Timed out waiting for terminal message')), 3000); - terminalClient.once('message', (data) => { - clearTimeout(timer); - expect(terminalClient.readyState).toBe(WebSocket.OPEN); - resolve(rawDataToString(data as RawData)); - }); - }); - - graphGateway.emitRunEvent(runId, threadId, { - runId, - threadId, - mutation: 'append', - event: { - id: 'evt-999', - runId, - threadId, - type: 'checkpoint', - status: 'running', - ts: new Date().toISOString(), - }, - }); - - const [runEventPayload, terminalFrame] = await Promise.all([runEventReceived, terminalMessage]); - expect(runEventPayload).toMatchObject({ runId, mutation: 'append' }); - expect(terminalFrame.length).toBeGreaterThan(0); - expect(terminalSessions.validations).toBeGreaterThan(0); - expect(terminalSessions.connects).toBeGreaterThan(0); - expect(terminalSessions.connected).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(terminalClient.readyState).toBe(WebSocket.OPEN); - expect(upgradeCount).toBeGreaterThanOrEqual(1); - } finally { - await waitForDisconnect(client); - terminalClient.close(); - await waitForWsClose(terminalClient); - fastify.server.off('upgrade', upgradeListener); - } - }); -}); diff --git a/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts b/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts index 66a7adbaf..b6e73d65a 100644 --- a/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts +++ b/packages/platform-server/__e2e__/llmSettings.adminStatus.e2e.test.ts @@ -15,6 +15,8 @@ describe('LLM settings controller (admin-status endpoint)', () => { agentsDbUrl: process.env.AGENTS_DATABASE_URL, litellmBaseUrl: process.env.LITELLM_BASE_URL, litellmMasterKey: process.env.LITELLM_MASTER_KEY, + notificationsRedisUrl: process.env.NOTIFICATIONS_REDIS_URL, + notificationsChannel: process.env.NOTIFICATIONS_CHANNEL, }; beforeAll(async () => { @@ -22,6 +24,8 @@ describe('LLM settings controller (admin-status endpoint)', () => { process.env.AGENTS_DATABASE_URL = 'postgres://localhost:5432/test'; process.env.LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000'; process.env.LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY || 'sk-dev-master-1234'; + process.env.NOTIFICATIONS_REDIS_URL = process.env.NOTIFICATIONS_REDIS_URL || 'redis://localhost:6379/0'; + process.env.NOTIFICATIONS_CHANNEL = process.env.NOTIFICATIONS_CHANNEL || 'notifications.v1'; ConfigService.clearInstanceForTest(); ConfigService.fromEnv(); @@ -42,6 +46,8 @@ describe('LLM settings controller (admin-status endpoint)', () => { process.env.AGENTS_DATABASE_URL = previousEnv.agentsDbUrl; process.env.LITELLM_BASE_URL = previousEnv.litellmBaseUrl; process.env.LITELLM_MASTER_KEY = previousEnv.litellmMasterKey; + process.env.NOTIFICATIONS_REDIS_URL = previousEnv.notificationsRedisUrl; + process.env.NOTIFICATIONS_CHANNEL = previousEnv.notificationsChannel; }); it('injects ConfigService and serves admin status when LiteLLM env is configured', async () => { diff --git a/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts b/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts index dd0da50ac..a5706854e 100644 --- a/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts +++ b/packages/platform-server/__e2e__/llmSettings.models.e2e.test.ts @@ -15,6 +15,8 @@ describe('LLM settings controller (models endpoint)', () => { agentsDbUrl: process.env.AGENTS_DATABASE_URL, litellmBaseUrl: process.env.LITELLM_BASE_URL, litellmMasterKey: process.env.LITELLM_MASTER_KEY, + notificationsRedisUrl: process.env.NOTIFICATIONS_REDIS_URL, + notificationsChannel: process.env.NOTIFICATIONS_CHANNEL, }; beforeAll(async () => { @@ -22,6 +24,8 @@ describe('LLM settings controller (models endpoint)', () => { process.env.AGENTS_DATABASE_URL = 'postgres://localhost:5432/test'; process.env.LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000'; process.env.LITELLM_MASTER_KEY = process.env.LITELLM_MASTER_KEY || 'sk-dev-master-1234'; + process.env.NOTIFICATIONS_REDIS_URL = process.env.NOTIFICATIONS_REDIS_URL || 'redis://localhost:6379/0'; + process.env.NOTIFICATIONS_CHANNEL = process.env.NOTIFICATIONS_CHANNEL || 'notifications.v1'; ConfigService.clearInstanceForTest(); ConfigService.fromEnv(); @@ -43,6 +47,8 @@ describe('LLM settings controller (models endpoint)', () => { process.env.AGENTS_DATABASE_URL = previousEnv.agentsDbUrl; process.env.LITELLM_BASE_URL = previousEnv.litellmBaseUrl; process.env.LITELLM_MASTER_KEY = previousEnv.litellmMasterKey; + process.env.NOTIFICATIONS_REDIS_URL = previousEnv.notificationsRedisUrl; + process.env.NOTIFICATIONS_CHANNEL = previousEnv.notificationsChannel; }); it('returns model list via injected service', async () => { diff --git a/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts b/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts index e29160bd6..589f268e4 100644 --- a/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts +++ b/packages/platform-server/__tests__/__e2e__/llmProvisioner.bootstrap.test.ts @@ -18,6 +18,8 @@ describe('LiteLLMProvisioner bootstrap (DI smoke)', () => { LITELLM_BASE_URL: 'http://127.0.0.1:4000', LITELLM_MASTER_KEY: 'sk-test', AGENTS_DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/agents_test', + NOTIFICATIONS_REDIS_URL: 'redis://localhost:6379/0', + NOTIFICATIONS_CHANNEL: 'notifications.v1', }; beforeEach(() => { diff --git a/packages/platform-server/__tests__/app.module.smoke.test.ts b/packages/platform-server/__tests__/app.module.smoke.test.ts index d368556c8..6157f6b91 100644 --- a/packages/platform-server/__tests__/app.module.smoke.test.ts +++ b/packages/platform-server/__tests__/app.module.smoke.test.ts @@ -21,6 +21,8 @@ import { StartupRecoveryService } from '../src/core/services/startupRecovery.ser import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { LLMProvisioner } from '../src/llm/provisioners/llm.provisioner'; import { clearTestConfig, registerTestConfig } from './helpers/config'; +import { NotificationsBroker } from '../src/notifications/notifications.broker'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; process.env.LLM_PROVIDER = process.env.LLM_PROVIDER || 'litellm'; process.env.LITELLM_BASE_URL = process.env.LITELLM_BASE_URL || 'http://127.0.0.1:4000'; @@ -147,6 +149,15 @@ describe('AppModule bootstrap smoke test', () => { init: vi.fn().mockResolvedValue(undefined), getLLM: vi.fn().mockResolvedValue({ call: vi.fn() }), } satisfies Partial; + const notificationsBrokerStub = { + connect: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } satisfies Partial; + const notificationsPublisherStub = { + onModuleInit: vi.fn(), + onModuleDestroy: vi.fn(), + } satisfies Partial; const config = registerTestConfig({ llmProvider: process.env.LLM_PROVIDER === 'openai' ? 'openai' : 'litellm', @@ -186,6 +197,10 @@ describe('AppModule bootstrap smoke test', () => { .useValue(liveRuntimeStub) .overrideProvider(LLMProvisioner) .useValue(llmProvisionerStub) + .overrideProvider(NotificationsBroker) + .useValue(notificationsBrokerStub) + .overrideProvider(NotificationsPublisher) + .useValue(notificationsPublisherStub) .overrideProvider(ConfigService) .useValue(config) .compile(); diff --git a/packages/platform-server/__tests__/config.service.fromEnv.test.ts b/packages/platform-server/__tests__/config.service.fromEnv.test.ts index d1f7adbb5..0cf0d1dc4 100644 --- a/packages/platform-server/__tests__/config.service.fromEnv.test.ts +++ b/packages/platform-server/__tests__/config.service.fromEnv.test.ts @@ -7,6 +7,8 @@ const previousEnv: Record = { litellmBaseUrl: process.env.LITELLM_BASE_URL, litellmMasterKey: process.env.LITELLM_MASTER_KEY, agentsDbUrl: process.env.AGENTS_DATABASE_URL, + notificationsRedisUrl: process.env.NOTIFICATIONS_REDIS_URL, + notificationsChannel: process.env.NOTIFICATIONS_CHANNEL, }; describe('ConfigService.fromEnv', () => { @@ -15,6 +17,8 @@ describe('ConfigService.fromEnv', () => { process.env.LITELLM_BASE_URL = previousEnv.litellmBaseUrl; process.env.LITELLM_MASTER_KEY = previousEnv.litellmMasterKey; process.env.AGENTS_DATABASE_URL = previousEnv.agentsDbUrl; + process.env.NOTIFICATIONS_REDIS_URL = previousEnv.notificationsRedisUrl; + process.env.NOTIFICATIONS_CHANNEL = previousEnv.notificationsChannel; ConfigService.clearInstanceForTest(); }); @@ -23,6 +27,8 @@ describe('ConfigService.fromEnv', () => { process.env.LITELLM_BASE_URL = 'http://127.0.0.1:4000/'; process.env.LITELLM_MASTER_KEY = ' sk-dev-master-1234 '; process.env.AGENTS_DATABASE_URL = 'postgresql://agents:agents@localhost:5443/agents'; + process.env.NOTIFICATIONS_REDIS_URL = 'redis://localhost:6379/0'; + process.env.NOTIFICATIONS_CHANNEL = 'notifications.v1'; const config = ConfigService.fromEnv(); diff --git a/packages/platform-server/__tests__/graph.module.di.smoke.test.ts b/packages/platform-server/__tests__/graph.module.di.smoke.test.ts index 1b6dde772..c5580d7e1 100644 --- a/packages/platform-server/__tests__/graph.module.di.smoke.test.ts +++ b/packages/platform-server/__tests__/graph.module.di.smoke.test.ts @@ -23,8 +23,8 @@ import { ArchiveService } from '../src/infra/archive/archive.service'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { GraphRepository } from '../src/graph/graph.repository'; import { ModuleRef } from '@nestjs/core'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import { GatewayModule } from '../src/gateway/gateway.module'; +import { NotificationsModule } from '../src/notifications/notifications.module'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { runnerConfigDefaults } from './helpers/config'; @@ -172,6 +172,8 @@ if (!shouldRunDbTests) { configSchema.parse({ llmProvider: 'openai', agentsDatabaseUrl: 'postgres://localhost:5432/test', + notificationsRedisUrl: 'redis://localhost:6379/0', + notificationsChannel: 'notifications.v1', ...runnerConfigDefaults, }), ); @@ -191,7 +193,7 @@ if (!shouldRunDbTests) { } satisfies Partial; const builder = Test.createTestingModule({ - imports: [GraphApiModule, GatewayModule], + imports: [GraphApiModule, NotificationsModule], }); const liveRuntimeStub = ({ @@ -223,15 +225,11 @@ if (!shouldRunDbTests) { builder.overrideProvider(ArchiveService).useFactory(() => makeStub({})); builder.overrideProvider(TemplateRegistry).useFactory(() => templateRegistryStub as TemplateRegistry); builder.overrideProvider(GraphRepository).useFactory(() => graphRepositoryStub as GraphRepository); - builder.overrideProvider(GraphSocketGateway).useValue({ - emitNodeState: vi.fn(), - emitThreadCreated: vi.fn(), - emitThreadUpdated: vi.fn(), - emitRunEvent: vi.fn(), - emitRunStatusChanged: vi.fn(), + builder.overrideProvider(NotificationsPublisher).useValue({ + onModuleInit: vi.fn(), + onModuleDestroy: vi.fn(), scheduleThreadMetrics: vi.fn(), - scheduleThreadAndAncestorsMetrics: vi.fn(), - } as unknown as GraphSocketGateway); + } as unknown as NotificationsPublisher); builder.useMocker((_token) => makeStub({})); diff --git a/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts b/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts deleted file mode 100644 index 0fcb7e70c..000000000 --- a/packages/platform-server/__tests__/graph.socket.gateway.bus.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { EventsBusService, ReminderCountEvent, RunEventBusPayload } from '../src/events/events-bus.service'; -import type { ToolOutputChunkPayload, ToolOutputTerminalPayload } from '../src/events/run-events.service'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; - -type Handler = ((payload: T) => void) | null; - -type GatewayTestContext = { - gateway: GraphSocketGateway; - handlers: { - run: Handler; - chunk: Handler; - terminal: Handler; - reminder: Handler; - nodeState: Handler<{ nodeId: string; state: Record; updatedAtMs?: number }>; - threadCreated: Handler<{ id: string }>; - threadUpdated: Handler<{ id: string }>; - messageCreated: Handler<{ threadId: string; message: { id: string } }>; - runStatus: Handler<{ threadId: string; run: { id: string; status: string; createdAt: Date; updatedAt: Date } }>; - threadMetrics: Handler<{ threadId: string }>; - threadMetricsAncestors: Handler<{ threadId: string }>; - }; - disposers: Record>; - logger: { warn: ReturnType; error: ReturnType; log: ReturnType; debug: ReturnType }; -}; - -function createGatewayTestContext(): GatewayTestContext { - const handlers: GatewayTestContext['handlers'] = { - run: null, - chunk: null, - terminal: null, - reminder: null, - nodeState: null, - threadCreated: null, - threadUpdated: null, - messageCreated: null, - runStatus: null, - threadMetrics: null, - threadMetricsAncestors: null, - }; - const disposers: GatewayTestContext['disposers'] = { - run: vi.fn(), - chunk: vi.fn(), - terminal: vi.fn(), - reminder: vi.fn(), - nodeState: vi.fn(), - threadCreated: vi.fn(), - threadUpdated: vi.fn(), - messageCreated: vi.fn(), - runStatus: vi.fn(), - threadMetrics: vi.fn(), - threadMetricsAncestors: vi.fn(), - }; - - const eventsBus: Pick< - EventsBusService, - | 'subscribeToRunEvents' - | 'subscribeToToolOutputChunk' - | 'subscribeToToolOutputTerminal' - | 'subscribeToReminderCount' - | 'subscribeToNodeState' - | 'subscribeToThreadCreated' - | 'subscribeToThreadUpdated' - | 'subscribeToMessageCreated' - | 'subscribeToRunStatusChanged' - | 'subscribeToThreadMetrics' - | 'subscribeToThreadMetricsAncestors' - > = { - subscribeToRunEvents: (listener) => { - handlers.run = listener; - return disposers.run; - }, - subscribeToToolOutputChunk: (listener) => { - handlers.chunk = listener; - return disposers.chunk; - }, - subscribeToToolOutputTerminal: (listener) => { - handlers.terminal = listener; - return disposers.terminal; - }, - subscribeToReminderCount: (listener) => { - handlers.reminder = listener; - return disposers.reminder; - }, - subscribeToNodeState: (listener) => { - handlers.nodeState = listener; - return disposers.nodeState; - }, - subscribeToThreadCreated: (listener) => { - handlers.threadCreated = listener; - return disposers.threadCreated; - }, - subscribeToThreadUpdated: (listener) => { - handlers.threadUpdated = listener; - return disposers.threadUpdated; - }, - subscribeToMessageCreated: (listener) => { - handlers.messageCreated = listener; - return disposers.messageCreated; - }, - subscribeToRunStatusChanged: (listener) => { - handlers.runStatus = listener; - return disposers.runStatus; - }, - subscribeToThreadMetrics: (listener) => { - handlers.threadMetrics = listener; - return disposers.threadMetrics; - }, - subscribeToThreadMetricsAncestors: (listener) => { - handlers.threadMetricsAncestors = listener; - return disposers.threadMetricsAncestors; - }, - }; - - const runtime = { subscribe: vi.fn() } as any; - const metrics = { getThreadsMetrics: vi.fn().mockResolvedValue({}) } as any; - const prisma = { getClient: vi.fn().mockReturnValue({ $queryRaw: vi.fn().mockResolvedValue([]) }) } as any; - - const gateway = new GraphSocketGateway(runtime, metrics, prisma, eventsBus as EventsBusService); - const internalLogger = (gateway as unknown as { logger: { warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void; log: (...args: unknown[]) => void; debug: (...args: unknown[]) => void } }).logger; - const logger = { - warn: vi.spyOn(internalLogger, 'warn').mockImplementation(() => undefined), - error: vi.spyOn(internalLogger, 'error').mockImplementation(() => undefined), - log: vi.spyOn(internalLogger, 'log').mockImplementation(() => undefined), - debug: vi.spyOn(internalLogger, 'debug').mockImplementation(() => undefined), - }; - gateway.onModuleInit(); - - return { gateway, handlers, disposers, logger }; -} - -describe('GraphSocketGateway event bus integration', () => { - let ctx: GatewayTestContext; - - beforeEach(() => { - ctx = createGatewayTestContext(); - }); - - it('emits run events for bus payloads', () => { - const spy = vi.spyOn(ctx.gateway, 'emitRunEvent'); - ctx.handlers.run?.({ - eventId: 'evt-1', - mutation: 'append', - event: { - id: 'evt-1', - runId: 'run-1', - threadId: 'thread-1', - type: 'tool_execution', - status: 'success', - ts: new Date().toISOString(), - startedAt: null, - endedAt: null, - durationMs: null, - nodeId: null, - sourceKind: 'system', - sourceSpanId: null, - metadata: null, - errorCode: null, - errorMessage: null, - attachments: [], - } as any, - }); - expect(spy).toHaveBeenCalledWith('run-1', 'thread-1', expect.objectContaining({ mutation: 'append' })); - }); - - it('converts tool output chunk timestamps to Date objects', () => { - const spy = vi.spyOn(ctx.gateway, 'emitToolOutputChunk'); - ctx.handlers.chunk?.({ - runId: 'run-1', - threadId: 'thread-1', - eventId: 'event-1', - seqGlobal: 1, - seqStream: 1, - source: 'stdout', - ts: '2025-01-01T00:00:00.000Z', - data: 'chunk', - }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'run-1', - threadId: 'thread-1', - eventId: 'event-1', - ts: expect.any(Date), - }), - ); - const payload = spy.mock.calls[0]?.[0]; - expect(payload.ts.toISOString()).toBe('2025-01-01T00:00:00.000Z'); - }); - - it('logs and skips invalid chunk timestamps', () => { - const spy = vi.spyOn(ctx.gateway, 'emitToolOutputChunk'); - ctx.handlers.chunk?.({ - runId: 'run-1', - threadId: 'thread-1', - eventId: 'event-1', - seqGlobal: 1, - seqStream: 1, - source: 'stdout', - ts: 'invalid', - data: 'chunk', - }); - expect(spy).not.toHaveBeenCalled(); - expect(ctx.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('GraphSocketGateway received invalid chunk timestamp'), - ); - }); - - it('emits tool output terminal payloads', () => { - const spy = vi.spyOn(ctx.gateway, 'emitToolOutputTerminal'); - ctx.handlers.terminal?.({ - runId: 'run-1', - threadId: 'thread-1', - eventId: 'event-1', - exitCode: 0, - status: 'success', - bytesStdout: 10, - bytesStderr: 0, - totalChunks: 1, - droppedChunks: 0, - savedPath: null, - message: null, - ts: '2025-01-01T00:00:00.000Z', - }); - expect(spy).toHaveBeenCalledWith( - expect.objectContaining({ - runId: 'run-1', - threadId: 'thread-1', - eventId: 'event-1', - ts: expect.any(Date), - status: 'success', - }), - ); - }); - - it('bridges reminder_count events to metrics scheduling', () => { - const reminderSpy = vi.spyOn(ctx.gateway, 'emitReminderCount'); - const scheduleAncestors = vi - .spyOn(ctx.gateway, 'scheduleThreadAndAncestorsMetrics') - .mockResolvedValue(); - ctx.handlers.reminder?.({ nodeId: 'node-1', count: 2, updatedAtMs: 123, threadId: 'thread-1' }); - expect(reminderSpy).toHaveBeenCalledWith('node-1', 2, 123); - expect(scheduleAncestors).toHaveBeenCalledWith('thread-1'); - }); - - it('forwards node_state events to emitNodeState', () => { - const spy = vi.spyOn(ctx.gateway, 'emitNodeState'); - ctx.handlers.nodeState?.({ nodeId: 'node-1', state: { value: 1 }, updatedAtMs: 10 }); - expect(spy).toHaveBeenCalledWith('node-1', { value: 1 }, 10); - }); - - it('emits thread and message events', () => { - const threadCreated = vi.spyOn(ctx.gateway, 'emitThreadCreated'); - const threadUpdated = vi.spyOn(ctx.gateway, 'emitThreadUpdated'); - const messageCreated = vi.spyOn(ctx.gateway, 'emitMessageCreated'); - const runStatus = vi.spyOn(ctx.gateway, 'emitRunStatusChanged'); - - ctx.handlers.threadCreated?.({ - id: 'thread-1', - alias: 't', - summary: null, - status: 'open', - createdAt: new Date(), - parentId: null, - channelNodeId: null, - } as any); - ctx.handlers.threadUpdated?.({ - id: 'thread-2', - alias: 't2', - summary: null, - status: 'open', - createdAt: new Date(), - parentId: null, - channelNodeId: null, - } as any); - ctx.handlers.messageCreated?.({ threadId: 'thread-1', message: { id: 'msg-1', kind: 'user', text: 'hi', source: {}, createdAt: new Date() } as any }); - ctx.handlers.runStatus?.({ threadId: 'thread-1', run: { id: 'run-1', status: 'running', createdAt: new Date(), updatedAt: new Date() } }); - - expect(threadCreated).toHaveBeenCalled(); - expect(threadUpdated).toHaveBeenCalled(); - expect(messageCreated).toHaveBeenCalledWith('thread-1', expect.objectContaining({ id: 'msg-1' })); - expect(runStatus).toHaveBeenCalledWith('thread-1', expect.objectContaining({ id: 'run-1' })); - }); - - it('schedules metrics for thread_metrics events', () => { - const schedule = vi.spyOn(ctx.gateway, 'scheduleThreadMetrics').mockImplementation(() => undefined); - const scheduleAncestors = vi.spyOn(ctx.gateway, 'scheduleThreadAndAncestorsMetrics').mockResolvedValue(); - ctx.handlers.threadMetrics?.({ threadId: 'thread-1' }); - expect(schedule).toHaveBeenCalledWith('thread-1'); - ctx.handlers.threadMetricsAncestors?.({ threadId: 'thread-2' }); - expect(scheduleAncestors).toHaveBeenCalledWith('thread-2'); - }); - - it('cleans up subscriptions on destroy', () => { - ctx.gateway.onModuleDestroy(); - expect(ctx.disposers.run).toHaveBeenCalledTimes(1); - expect(ctx.disposers.chunk).toHaveBeenCalledTimes(1); - expect(ctx.disposers.terminal).toHaveBeenCalledTimes(1); - expect(ctx.disposers.reminder).toHaveBeenCalledTimes(1); - expect(ctx.disposers.nodeState).toHaveBeenCalledTimes(1); - expect(ctx.disposers.threadCreated).toHaveBeenCalledTimes(1); - expect(ctx.disposers.threadUpdated).toHaveBeenCalledTimes(1); - expect(ctx.disposers.messageCreated).toHaveBeenCalledTimes(1); - expect(ctx.disposers.runStatus).toHaveBeenCalledTimes(1); - expect(ctx.disposers.threadMetrics).toHaveBeenCalledTimes(1); - expect(ctx.disposers.threadMetricsAncestors).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/platform-server/__tests__/helpers/config.ts b/packages/platform-server/__tests__/helpers/config.ts index 8494ab02f..bba91ac14 100644 --- a/packages/platform-server/__tests__/helpers/config.ts +++ b/packages/platform-server/__tests__/helpers/config.ts @@ -3,6 +3,8 @@ import { ConfigService, configSchema } from '../../src/core/services/config.serv export const runnerConfigDefaults = { dockerRunnerBaseUrl: 'http://docker-runner:7071', dockerRunnerSharedSecret: 'test-shared-secret', + notificationsRedisUrl: 'redis://localhost:6379/0', + notificationsChannel: 'notifications.v1', } as const; const defaultConfigInput = { diff --git a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts b/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts index 222cac5c7..c22de7440 100644 --- a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts +++ b/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts @@ -13,7 +13,6 @@ import { ModuleRef } from '@nestjs/core'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { GraphRepository } from '../src/graph/graph.repository'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; import type { GraphDefinition, PersistedGraph } from '../src/shared/types/graph.types'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { RunSignalsRegistry } from '../src/agents/run-signals.service'; @@ -110,6 +109,8 @@ class StubConfigService extends ConfigService { ncpsAuthToken: undefined, agentsDatabaseUrl: 'postgres://localhost:5432/agents', corsOrigins: [], + notificationsRedisUrl: 'redis://localhost:6379/0', + notificationsChannel: 'notifications.v1', }); } } @@ -144,7 +145,6 @@ describe('Boot respects MCP enabledTools from persisted state', () => { { provide: NcpsKeyService, useValue: { getKeysForInjection: () => [] } }, { provide: ContainerRegistry, useValue: { updateLastUsed: async () => {}, registerStart: async () => {}, markStopped: async () => {} } }, { provide: WorkspaceProvider, useClass: WorkspaceProviderStub }, - { provide: GraphSocketGateway, useValue: { emitNodeState: (_id: string, _state: Record) => {} } }, NodeStateService, TemplateRegistry, LiveGraphRuntime, diff --git a/packages/platform-server/__tests__/notifications.publisher.bus.test.ts b/packages/platform-server/__tests__/notifications.publisher.bus.test.ts new file mode 100644 index 000000000..f1a0acef3 --- /dev/null +++ b/packages/platform-server/__tests__/notifications.publisher.bus.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + EventsBusService, + ReminderCountEvent, + RunEventBusPayload, +} from '../src/events/events-bus.service'; +import type { ToolOutputChunkPayload, ToolOutputTerminalPayload } from '../src/events/run-events.service'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; + +type Handler = ((payload: T) => void) | null; + +const RUN_ID = '00000000-0000-4000-8000-000000000001'; +const THREAD_ID = '00000000-0000-4000-8000-000000000002'; +const EVENT_ID = '00000000-0000-4000-8000-000000000003'; +const MESSAGE_ID = '00000000-0000-4000-8000-000000000004'; + +type PublisherTestContext = { + publisher: NotificationsPublisher; + handlers: { + run: Handler; + chunk: Handler; + terminal: Handler; + reminder: Handler; + nodeState: Handler<{ nodeId: string; state: Record; updatedAtMs?: number }>; + threadCreated: Handler<{ id: string }>; + threadUpdated: Handler<{ id: string }>; + messageCreated: Handler<{ threadId: string; message: { id: string; kind: string; createdAt: Date } }>; + runStatus: Handler<{ threadId: string; run: { id: string; status: string; createdAt: Date; updatedAt: Date } }>; + threadMetrics: Handler<{ threadId: string }>; + threadMetricsAncestors: Handler<{ threadId: string }>; + }; + broker: { publish: ReturnType }; + logger: { warn: ReturnType }; +}; + +async function createPublisherTestContext(): Promise { + const handlers: PublisherTestContext['handlers'] = { + run: null, + chunk: null, + terminal: null, + reminder: null, + nodeState: null, + threadCreated: null, + threadUpdated: null, + messageCreated: null, + runStatus: null, + threadMetrics: null, + threadMetricsAncestors: null, + }; + const disposers = Object.fromEntries( + Object.keys(handlers).map((key) => [key, vi.fn(() => undefined)]), + ) as Record>; + + const eventsBus: Pick< + EventsBusService, + | 'subscribeToRunEvents' + | 'subscribeToToolOutputChunk' + | 'subscribeToToolOutputTerminal' + | 'subscribeToReminderCount' + | 'subscribeToNodeState' + | 'subscribeToThreadCreated' + | 'subscribeToThreadUpdated' + | 'subscribeToMessageCreated' + | 'subscribeToRunStatusChanged' + | 'subscribeToThreadMetrics' + | 'subscribeToThreadMetricsAncestors' + > = { + subscribeToRunEvents: (listener) => { + handlers.run = listener; + return disposers.run; + }, + subscribeToToolOutputChunk: (listener) => { + handlers.chunk = listener; + return disposers.chunk; + }, + subscribeToToolOutputTerminal: (listener) => { + handlers.terminal = listener; + return disposers.terminal; + }, + subscribeToReminderCount: (listener) => { + handlers.reminder = listener; + return disposers.reminder; + }, + subscribeToNodeState: (listener) => { + handlers.nodeState = listener; + return disposers.nodeState; + }, + subscribeToThreadCreated: (listener) => { + handlers.threadCreated = listener; + return disposers.threadCreated; + }, + subscribeToThreadUpdated: (listener) => { + handlers.threadUpdated = listener; + return disposers.threadUpdated; + }, + subscribeToMessageCreated: (listener) => { + handlers.messageCreated = listener; + return disposers.messageCreated; + }, + subscribeToRunStatusChanged: (listener) => { + handlers.runStatus = listener; + return disposers.runStatus; + }, + subscribeToThreadMetrics: (listener) => { + handlers.threadMetrics = listener; + return disposers.threadMetrics; + }, + subscribeToThreadMetricsAncestors: (listener) => { + handlers.threadMetricsAncestors = listener; + return disposers.threadMetricsAncestors; + }, + }; + + const runtime = { subscribe: vi.fn().mockReturnValue(() => undefined) } as any; + const metrics = { getThreadsMetrics: vi.fn().mockResolvedValue({}) } as any; + const prisma = { getClient: vi.fn().mockReturnValue({ $queryRaw: vi.fn().mockResolvedValue([]) }) } as any; + const broker = { + connect: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + + const publisher = new NotificationsPublisher(runtime, metrics, prisma, eventsBus as EventsBusService, broker as any); + const internalLogger = (publisher as unknown as { logger: { warn: (...args: unknown[]) => void } }).logger; + const logger = { + warn: vi.spyOn(internalLogger, 'warn').mockImplementation(() => undefined), + }; + await publisher.onModuleInit(); + + return { publisher, handlers, broker, logger }; +} + +describe('NotificationsPublisher event bus integration', () => { + let ctx: PublisherTestContext; + + beforeEach(async () => { + ctx = await createPublisherTestContext(); + }); + + afterEach(async () => { + await ctx.publisher.onModuleDestroy(); + }); + + it('publishes run events for bus payloads', () => { + ctx.handlers.run?.({ + eventId: EVENT_ID, + mutation: 'append', + event: { + id: EVENT_ID, + runId: RUN_ID, + threadId: THREAD_ID, + type: 'tool_execution', + status: 'success', + ts: new Date().toISOString(), + startedAt: null, + endedAt: null, + durationMs: null, + nodeId: null, + sourceKind: 'system', + sourceSpanId: null, + metadata: null, + errorCode: null, + errorMessage: null, + attachments: [], + } as any, + }); + + expect(ctx.broker.publish).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'run_event_appended', + rooms: expect.arrayContaining([`run:${RUN_ID}`, `thread:${THREAD_ID}`]), + }), + ); + }); + + it('converts tool output chunk timestamps to ISO strings', () => { + ctx.handlers.chunk?.({ + runId: RUN_ID, + threadId: THREAD_ID, + eventId: EVENT_ID, + seqGlobal: 1, + seqStream: 1, + source: 'stdout', + ts: '2025-01-01T00:00:00.000Z', + data: 'chunk', + }); + + const envelope = ctx.broker.publish.mock.calls.find(([call]) => (call as { event: string }).event === 'tool_output_chunk')?.[0]; + expect(envelope).toBeDefined(); + expect((envelope as { payload: { ts: string } }).payload.ts).toBe('2025-01-01T00:00:00.000Z'); + }); + + it('logs and skips invalid chunk timestamps', () => { + ctx.handlers.chunk?.({ + runId: RUN_ID, + threadId: THREAD_ID, + eventId: EVENT_ID, + seqGlobal: 1, + seqStream: 1, + source: 'stdout', + ts: 'invalid', + data: 'chunk', + } as any); + + expect(ctx.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('NotificationsPublisher received invalid chunk timestamp'), + ); + }); + + it('publishes reminder counts and schedules thread metrics', () => { + ctx.handlers.reminder?.({ nodeId: 'node-1', count: 2, threadId: THREAD_ID, updatedAtMs: 10 } as ReminderCountEvent); + expect(ctx.logger.warn).not.toHaveBeenCalled(); + expect(ctx.broker.publish).toHaveBeenCalledWith(expect.objectContaining({ event: 'node_reminder_count' })); + }); + + it('publishes node state updates', () => { + ctx.handlers.nodeState?.({ nodeId: 'node-1', state: { foo: 'bar' }, updatedAtMs: 5 }); + expect(ctx.broker.publish).toHaveBeenCalledWith(expect.objectContaining({ event: 'node_state' })); + }); + + it('publishes message created events', () => { + ctx.handlers.messageCreated?.({ + threadId: THREAD_ID, + message: { + id: MESSAGE_ID, + kind: 'user', + text: 'hello', + source: null, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + } as any, + }); + expect(ctx.broker.publish).toHaveBeenCalledWith( + expect.objectContaining({ event: 'message_created', rooms: expect.arrayContaining([`thread:${THREAD_ID}`]) }), + ); + }); + + it('publishes run status updates', () => { + ctx.handlers.runStatus?.({ + threadId: THREAD_ID, + run: { + id: RUN_ID, + status: 'running', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:01:00.000Z'), + }, + } as any); + expect(ctx.broker.publish).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'run_status_changed', + rooms: expect.arrayContaining([`thread:${THREAD_ID}`, `run:${RUN_ID}`]), + }), + ); + }); +}); diff --git a/packages/platform-server/__tests__/run-events.publish.test.ts b/packages/platform-server/__tests__/run-events.publish.test.ts index 3d8bc2774..168cf28ad 100644 --- a/packages/platform-server/__tests__/run-events.publish.test.ts +++ b/packages/platform-server/__tests__/run-events.publish.test.ts @@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto'; import type { PrismaService } from '../src/core/services/prisma.service'; import { RunEventsService, type RunTimelineEvent } from '../src/events/run-events.service'; import { EventsBusService } from '../src/events/events-bus.service'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; const databaseUrl = process.env.AGENTS_DATABASE_URL; const shouldRunDbTests = process.env.RUN_DB_TESTS === 'true' && !!databaseUrl; @@ -28,18 +27,13 @@ maybeDescribe('RunEventsService publishEvent broadcasting', () => { let runEvents: RunEventsService; let eventsBus: EventsBusService; - let gateway: GraphSocketGateway; - let emitRunEventSpy: ReturnType; + let events: RunEventBusPayload[]; beforeEach(async () => { runEvents = new RunEventsService(prismaService); eventsBus = new EventsBusService(runEvents); - const runtime = { subscribe: vi.fn() } as any; - const metrics = { getThreadsMetrics: vi.fn().mockResolvedValue({}) } as any; - const prismaStub = { getClient: vi.fn().mockReturnValue({ $queryRaw: vi.fn().mockResolvedValue([]) }) } as any; - gateway = new GraphSocketGateway(runtime, metrics, prismaStub, eventsBus); - emitRunEventSpy = vi.spyOn(gateway, 'emitRunEvent'); - await gateway.onModuleInit(); + events = []; + eventsBus.subscribeToRunEvents((payload) => events.push(payload)); }); afterAll(async () => { @@ -47,8 +41,7 @@ maybeDescribe('RunEventsService publishEvent broadcasting', () => { }); afterEach(() => { - gateway.onModuleDestroy(); - emitRunEventSpy.mockRestore(); + events = []; }); it('emits append and update payloads with tool execution data', async () => { @@ -65,19 +58,17 @@ maybeDescribe('RunEventsService publishEvent broadcasting', () => { const appendEvent = await eventsBus.publishEvent(started.id, 'append'); expect(appendEvent?.status).toBe('running'); - expect(emitRunEventSpy).toHaveBeenCalledTimes(1); - const appendRecord = emitRunEventSpy.mock.calls[0]; - expect(appendRecord[0]).toBe(run.id); - expect(appendRecord[1]).toBe(thread.id); - expect(appendRecord[2].mutation).toBe('append'); - const appendedEvent = appendRecord[2].event as RunTimelineEvent; - expect(appendedEvent.id).toBe(started.id); - expect(appendedEvent.status).toBe('running'); - expect(appendedEvent.toolExecution?.toolName).toBe('search'); - expect(appendedEvent.toolExecution?.input).toEqual({ query: 'status' }); - expect(appendedEvent.toolExecution?.output).toBeNull(); - - emitRunEventSpy.mockClear(); + expect(events).toHaveLength(1); + const appendedEvent = events[0]!; + expect(appendedEvent.runId).toBe(run.id); + expect(appendedEvent.mutation).toBe('append'); + const appendedSnapshot = appendedEvent.event as RunTimelineEvent; + expect(appendedSnapshot.id).toBe(started.id); + expect(appendedSnapshot.status).toBe('running'); + expect(appendedSnapshot.toolExecution?.toolName).toBe('search'); + expect(appendedSnapshot.toolExecution?.input).toEqual({ query: 'status' }); + expect(appendedSnapshot.toolExecution?.output).toBeNull(); + events = []; await runEvents.completeToolExecution({ eventId: started.id, @@ -89,12 +80,11 @@ maybeDescribe('RunEventsService publishEvent broadcasting', () => { const updateEvent = await eventsBus.publishEvent(started.id, 'update'); expect(updateEvent?.status).toBe('success'); expect(updateEvent?.toolExecution?.output).toEqual({ answer: 42 }); - expect(emitRunEventSpy).toHaveBeenCalledTimes(1); - const updateRecord = emitRunEventSpy.mock.calls[0]; - expect(updateRecord[0]).toBe(run.id); - expect(updateRecord[1]).toBe(thread.id); - expect(updateRecord[2].mutation).toBe('update'); - const updatedEvent = updateRecord[2].event as RunTimelineEvent; + expect(events).toHaveLength(1); + const updatedPayload = events[0]!; + expect(updatedPayload.runId).toBe(run.id); + expect(updatedPayload.mutation).toBe('update'); + const updatedEvent = updatedPayload.event as RunTimelineEvent; expect(updatedEvent.status).toBe('success'); expect(updatedEvent.toolExecution?.execStatus).toBe('success'); expect(updatedEvent.toolExecution?.output).toEqual({ answer: 42 }); diff --git a/packages/platform-server/__tests__/socket.events.test.ts b/packages/platform-server/__tests__/socket.events.test.ts deleted file mode 100644 index 6a2a597d8..000000000 --- a/packages/platform-server/__tests__/socket.events.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FastifyAdapter } from '@nestjs/platform-fastify'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import { PrismaService } from '../src/core/services/prisma.service'; -import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; -import Node from '../src/nodes/base/Node'; - -// Minimal Test Node to trigger status changes -class TestNode extends Node> { - getPortConfig() { return { sourcePorts: { $self: { kind: 'instance' } } } as const; } -} - -describe('Socket events', () => { - it('emits node_status on provision/deprovision', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - let listener: ((ev: { nodeId: string; prev: string; next: string; at: number }) => void) | undefined; - const runtimeStub = { subscribe: (fn: typeof listener) => { listener = fn; return () => {}; } } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as unknown as PrismaService; - const metrics = new ThreadsMetricsService(prismaStub as any); - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metrics, prismaStub, eventsBusStub as any); - gateway.init({ server: fastify.server }); - - const emitMap = new Map>(); - const toSpy = vi.fn((room: string) => { - if (!emitMap.has(room)) emitMap.set(room, vi.fn()); - return { emit: emitMap.get(room)! }; - }); - (gateway as any).io = { to: toSpy }; - - const node = new TestNode(); - node.init({ nodeId: 'n1' }); - // Simulate runtime status events - const now = Date.now(); - listener?.({ nodeId: 'n1', prev: 'not_ready', next: 'provisioning', at: now }); - listener?.({ nodeId: 'n1', prev: 'provisioning', next: 'ready', at: now + 1 }); - listener?.({ nodeId: 'n1', prev: 'ready', next: 'deprovisioning', at: now + 2 }); - listener?.({ nodeId: 'n1', prev: 'deprovisioning', next: 'not_ready', at: now + 3 }); - - expect(toSpy).toHaveBeenCalledWith('graph'); - expect(toSpy).toHaveBeenCalledWith('node:n1'); - const graphEmitter = emitMap.get('graph'); - const nodeEmitter = emitMap.get('node:n1'); - expect(graphEmitter).toBeTruthy(); - expect(nodeEmitter).toBeTruthy(); - expect(graphEmitter).toHaveBeenCalledTimes(4); - expect(nodeEmitter).toHaveBeenCalledTimes(4); - const payload = graphEmitter?.mock.calls[0]?.[1]; - expect(payload).toMatchObject({ nodeId: 'n1', provisionStatus: { state: 'provisioning' } }); - }); - - it('emits node_state via NodeStateService bridge', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as unknown as PrismaService; - const metrics = new ThreadsMetricsService(prismaStub as any); - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metrics, prismaStub, eventsBusStub as any); - gateway.init({ server: fastify.server }); - const emitMap = new Map>(); - const toSpy = vi.fn((room: string) => { - if (!emitMap.has(room)) emitMap.set(room, vi.fn()); - return { emit: emitMap.get(room)! }; - }); - (gateway as any).io = { to: toSpy }; - gateway.emitNodeState('n1', { k: 'v' }); - expect(toSpy).toHaveBeenCalledWith('graph'); - expect(toSpy).toHaveBeenCalledWith('node:n1'); - expect(emitMap.get('graph')).toHaveBeenCalledWith('node_state', expect.objectContaining({ nodeId: 'n1', state: { k: 'v' } })); - expect(emitMap.get('node:n1')).toHaveBeenCalledWith('node_state', expect.objectContaining({ nodeId: 'n1', state: { k: 'v' } })); - }); - - it('emits reminder count to graph and node rooms', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as unknown as PrismaService; - const metrics = new ThreadsMetricsService(prismaStub as any); - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metrics, prismaStub, eventsBusStub as any); - gateway.init({ server: fastify.server }); - const emitMap = new Map>(); - const toSpy = vi.fn((room: string) => { - if (!emitMap.has(room)) emitMap.set(room, vi.fn()); - return { emit: emitMap.get(room)! }; - }); - (gateway as any).io = { to: toSpy }; - gateway.emitReminderCount('n1', 3, Date.now()); - expect(toSpy).toHaveBeenCalledWith('graph'); - expect(toSpy).toHaveBeenCalledWith('node:n1'); - expect(emitMap.get('graph')).toHaveBeenCalledWith('node_reminder_count', expect.objectContaining({ nodeId: 'n1', count: 3 })); - expect(emitMap.get('node:n1')).toHaveBeenCalledWith('node_reminder_count', expect.objectContaining({ nodeId: 'n1', count: 3 })); - }); -}); diff --git a/packages/platform-server/__tests__/socket.gateway.test.ts b/packages/platform-server/__tests__/socket.gateway.test.ts deleted file mode 100644 index cf1efbb9f..000000000 --- a/packages/platform-server/__tests__/socket.gateway.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { FastifyAdapter } from '@nestjs/platform-fastify'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import { PrismaService } from '../src/core/services/prisma.service'; -import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; - -describe('GraphSocketGateway', () => { - it('gateway initializes without errors', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as unknown as PrismaService; - const metrics = new ThreadsMetricsService(prismaStub as any); - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metrics, prismaStub, eventsBusStub as any); - expect(() => gateway.init({ server: fastify.server })).not.toThrow(); - }); -}); diff --git a/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts b/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts index ed6c42b33..bd0bded86 100644 --- a/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts +++ b/packages/platform-server/__tests__/socket.metrics.coalesce.test.ts @@ -1,51 +1,57 @@ -import { describe, it, expect, vi } from 'vitest'; -import { FastifyAdapter } from '@nestjs/platform-fastify'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; + +describe('NotificationsPublisher metrics coalescing', () => { + const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; + const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as any; + const eventsBusStub = { + subscribeToRunEvents: () => () => {}, + subscribeToToolOutputChunk: () => () => {}, + subscribeToToolOutputTerminal: () => () => {}, + subscribeToReminderCount: () => () => {}, + subscribeToNodeState: () => () => {}, + subscribeToThreadCreated: () => () => {}, + subscribeToThreadUpdated: () => () => {}, + subscribeToMessageCreated: () => () => {}, + subscribeToRunStatusChanged: () => () => {}, + subscribeToThreadMetrics: () => () => {}, + subscribeToThreadMetricsAncestors: () => () => {}, + } as any; + + let metricsStub: { getThreadsMetrics: ReturnType }; + let brokerStub: { connect: ReturnType; publish: ReturnType; close: ReturnType }; + let publisher: NotificationsPublisher; + + beforeEach(() => { + metricsStub = { + getThreadsMetrics: vi.fn(async (ids: string[]) => + Object.fromEntries(ids.map((id) => [id, { remindersCount: 0, activity: 'idle' as const }])), + ), + }; + brokerStub = { + connect: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + publisher = new NotificationsPublisher(runtimeStub, metricsStub as any, prismaStub, eventsBusStub, brokerStub as any); + }); -describe('GraphSocketGateway metrics coalescing', () => { it('coalesces multiple schedules into single batch computation', async () => { vi.useFakeTimers(); - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - // Stub metrics service to capture calls - const getThreadsMetrics = vi.fn(async (_ids: string[]) => - Object.fromEntries(_ids.map((id) => [id, { remindersCount: 0, containersCount: 0, activity: 'idle' as const }])), - ); - const metricsStub = { getThreadsMetrics } as any; - const prismaStub = { getClient: () => ({ $queryRaw: async () => [] }) } as any; - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metricsStub, prismaStub, eventsBusStub as any); - // Attach and stub io emit sink - gateway.init({ server: fastify.server }); - const captured: Array<{ room: string; event: string; payload: any }> = []; - (gateway as any)['io'] = { to: (room: string) => ({ emit: (event: string, payload: any) => { captured.push({ room, event, payload }); } }) }; + publisher.scheduleThreadMetrics('t1'); + publisher.scheduleThreadMetrics('t2'); - gateway.scheduleThreadMetrics('t1'); - gateway.scheduleThreadMetrics('t2'); - // Advance timers to trigger flush - vi.advanceTimersByTime(120); + await vi.advanceTimersByTimeAsync(120); await Promise.resolve(); - // Assert single batch computation and grouped emits to both rooms - expect(getThreadsMetrics).toHaveBeenCalledTimes(1); - expect(getThreadsMetrics).toHaveBeenCalledWith(['t1', 't2']); - const activityThreadsRoom = captured.filter((e) => e.event === 'thread_activity_changed' && e.room === 'threads'); - const remindersThreadsRoom = captured.filter((e) => e.event === 'thread_reminders_count' && e.room === 'threads'); - expect(activityThreadsRoom.map((e) => e.payload.threadId).sort()).toEqual(['t1', 't2']); - expect(remindersThreadsRoom.map((e) => e.payload.threadId).sort()).toEqual(['t1', 't2']); + expect(metricsStub.getThreadsMetrics).toHaveBeenCalledTimes(1); + expect(metricsStub.getThreadsMetrics).toHaveBeenCalledWith(['t1', 't2']); + const envelopes = brokerStub.publish.mock.calls.map(([envelope]) => envelope as NotificationEnvelope); + const activityPayloads = envelopes.filter((e) => e.event === 'thread_activity_changed'); + const remindersPayloads = envelopes.filter((e) => e.event === 'thread_reminders_count'); + expect(activityPayloads.map((e) => e.payload.threadId).sort()).toEqual(['t1', 't2']); + expect(remindersPayloads.map((e) => e.payload.threadId).sort()).toEqual(['t1', 't2']); vi.useRealTimers(); }); }); diff --git a/packages/platform-server/__tests__/socket.node_status.integration.test.ts b/packages/platform-server/__tests__/socket.node_status.integration.test.ts deleted file mode 100644 index aa00be962..000000000 --- a/packages/platform-server/__tests__/socket.node_status.integration.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { FastifyAdapter } from '@nestjs/platform-fastify'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import Node from '../src/nodes/base/Node'; - -class DummyNode extends Node> { getPortConfig() { return { sourcePorts: { $self: { kind: 'instance' } } } as const; } } - -describe('Gateway node_status integration', () => { - it('broadcasts on node lifecycle changes', async () => { - const adapter = new FastifyAdapter(); - const fastify = adapter.getInstance(); - const runtimeStub = { subscribe: () => () => {} } as unknown as import('../src/graph-core/liveGraph.manager').LiveGraphRuntime; - const metricsStub = { getThreadsMetrics: async () => ({}) }; - const prismaStub = { - getClient: () => ({ - $queryRaw: async () => [], - }), - }; - const eventsBusStub = { - subscribeToRunEvents: () => () => {}, - subscribeToToolOutputChunk: () => () => {}, - subscribeToToolOutputTerminal: () => () => {}, - subscribeToReminderCount: () => () => {}, - subscribeToNodeState: () => () => {}, - subscribeToThreadCreated: () => () => {}, - subscribeToThreadUpdated: () => () => {}, - subscribeToMessageCreated: () => () => {}, - subscribeToRunStatusChanged: () => () => {}, - subscribeToThreadMetrics: () => () => {}, - subscribeToThreadMetricsAncestors: () => () => {}, - }; - const gateway = new GraphSocketGateway(runtimeStub, metricsStub as any, prismaStub as any, eventsBusStub as any); - gateway.init({ server: fastify.server }); - const node = new DummyNode(); - node.init({ nodeId: 'nX' }); - await node.provision(); - await node.deprovision(); - expect(true).toBe(true); - }); -}); diff --git a/packages/platform-server/__tests__/socket.realtime.integration.test.ts b/packages/platform-server/__tests__/socket.realtime.integration.test.ts deleted file mode 100644 index 9b5eaeef9..000000000 --- a/packages/platform-server/__tests__/socket.realtime.integration.test.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { describe, it, expect, afterAll } from 'vitest'; -import { createServer, type Server as HTTPServer } from 'http'; -import type { AddressInfo } from 'net'; -import { io as createClient, type Socket } from 'socket.io-client'; -import { randomUUID } from 'node:crypto'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; -import type { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; -import type { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; -import type { PrismaService } from '../src/core/services/prisma.service'; -import { PrismaClient, ToolExecStatus } from '@prisma/client'; -import { RunEventsService } from '../src/events/run-events.service'; -import { EventsBusService } from '../src/events/events-bus.service'; -import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; -import type { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { GraphRepository } from '../src/graph/graph.repository'; -import { HumanMessage, AIMessage } from '@agyn/llm'; -import { CallAgentLinkingService } from '../src/agents/call-agent-linking.service'; - -type MetricsPayload = { activity: 'working' | 'waiting' | 'idle'; remindersCount: number }; - -const createRuntimeStub = (): LiveGraphRuntime => - ({ - subscribe: () => () => undefined, - }) as unknown as LiveGraphRuntime; - -const createMetricsDouble = () => { - const store = new Map(); - const service = { - getThreadsMetrics: async (ids: string[]) => { - const out: Record = {}; - for (const id of ids) out[id] = store.get(id) ?? { activity: 'idle', remindersCount: 0 }; - return out; - }, - } as unknown as ThreadsMetricsService; - return { - service, - set(id: string, value: MetricsPayload) { - store.set(id, value); - }, - }; -}; - -const createEventsBusNoop = (): EventsBusService => - ({ - subscribeToRunEvents: () => () => undefined, - subscribeToToolOutputChunk: () => () => undefined, - subscribeToToolOutputTerminal: () => () => undefined, - subscribeToReminderCount: () => () => undefined, - subscribeToNodeState: () => () => undefined, - subscribeToThreadCreated: () => () => undefined, - subscribeToThreadUpdated: () => () => undefined, - subscribeToMessageCreated: () => () => undefined, - subscribeToRunStatusChanged: () => () => undefined, - subscribeToThreadMetrics: () => () => undefined, - subscribeToThreadMetricsAncestors: () => () => undefined, - }) as unknown as EventsBusService; - -const createPrismaStub = () => - ({ - getClient: () => ({ - $queryRaw: async () => [], - }), - }) as unknown as PrismaService; - -const createLinkingStub = () => - ({ - buildInitialMetadata: (params: { tool: 'call_agent' | 'call_engineer'; parentThreadId: string; childThreadId: string }) => ({ - tool: params.tool, - parentThreadId: params.parentThreadId, - childThreadId: params.childThreadId, - childRun: { id: null, status: 'queued', linkEnabled: false, latestMessageId: null }, - childRunId: null, - childRunStatus: 'queued', - childRunLinkEnabled: false, - childMessageId: null, - }), - registerParentToolExecution: async () => null, - onChildRunStarted: async () => null, - onChildRunMessage: async () => null, - onChildRunCompleted: async () => null, - resolveLinkedAgentNodes: async () => ({}), - }) as unknown as CallAgentLinkingService; - -const waitForEvent = (socket: Socket, event: string, timeoutMs = 5000): Promise => - new Promise((resolve, reject) => { - const timer = setTimeout(() => { - socket.off(event, handler); - reject(new Error(`Timed out waiting for ${event}`)); - }, timeoutMs); - const handler = (payload: T) => { - clearTimeout(timer); - socket.off(event, handler); - resolve(payload); - }; - socket.on(event, handler); - }); - -const subscribeRooms = async (socket: Socket, rooms: string[]) => { - socket.emit('subscribe', { rooms }); - await new Promise((resolve) => setTimeout(resolve, 20)); -}; - -const closeClient = async (socket: Socket) => - new Promise((resolve) => { - if (!socket.connected) { - socket.removeAllListeners(); - resolve(); - return; - } - socket.once('disconnect', () => { - socket.removeAllListeners(); - resolve(); - }); - socket.disconnect(); - }); - -const closeServer = async (server: HTTPServer) => - new Promise((resolve) => { - server.close(() => resolve()); - }); - -const shouldRunRealtimeTests = process.env.RUN_DB_TESTS === 'true' && !!process.env.AGENTS_DATABASE_URL; - -if (!shouldRunRealtimeTests) { - describe.skip('GraphSocketGateway realtime integration', () => { - it('skipped because RUN_DB_TESTS is not true', () => { - expect(true).toBe(true); - }); - }); -} else { - const DATABASE_URL = process.env.AGENTS_DATABASE_URL as string; - const prisma = new PrismaClient({ datasources: { db: { url: DATABASE_URL } } }); - - describe.sequential('GraphSocketGateway realtime integration', () => { - afterAll(async () => { - await prisma.$disconnect(); - }); - - it('broadcasts thread lifecycle and metrics events to subscribers', async () => { - const runtime = createRuntimeStub(); - const metricsDouble = createMetricsDouble(); - const prismaStub = createPrismaStub(); - const server = createServer(); - await new Promise((resolve) => server.listen(0, resolve)); - const { port } = server.address() as AddressInfo; - const eventsBus = createEventsBusNoop(); - const gateway = new GraphSocketGateway(runtime, metricsDouble.service, prismaStub, eventsBus); - gateway.onModuleInit(); - gateway.init({ server }); - - const client = createClient(`http://127.0.0.1:${port}`, { path: '/socket.io', transports: ['websocket'] }); - await new Promise((resolve, reject) => { - client.once('connect', () => resolve()); - client.once('connect_error', (err) => reject(err)); - }); - - const threadId = randomUUID(); - await subscribeRooms(client, ['threads', `thread:${threadId}`]); - - const createdPromise = waitForEvent<{ thread: { id: string } }>(client, 'thread_created'); - gateway.emitThreadCreated({ - id: threadId, - alias: 't', - summary: null, - status: 'open', - createdAt: new Date(), - parentId: null, - channelNodeId: null, - }); - const createdPayload = await createdPromise; - expect(createdPayload.thread.id).toBe(threadId); - - const updatedPromise = waitForEvent<{ thread: { summary: string | null } }>(client, 'thread_updated'); - gateway.emitThreadUpdated({ - id: threadId, - alias: 't', - summary: 'Updated summary', - status: 'open', - createdAt: new Date(), - parentId: null, - channelNodeId: null, - }); - const updatedPayload = await updatedPromise; - expect(updatedPayload.thread.summary).toBe('Updated summary'); - - metricsDouble.set(threadId, { activity: 'working', remindersCount: 2 }); - const activityPromise = waitForEvent<{ threadId: string; activity: string }>(client, 'thread_activity_changed'); - const remindersPromise = waitForEvent<{ threadId: string; remindersCount: number }>(client, 'thread_reminders_count'); - gateway.scheduleThreadMetrics(threadId); - const [activityPayload, remindersPayload] = await Promise.all([activityPromise, remindersPromise]); - expect(activityPayload).toEqual({ threadId, activity: 'working' }); - expect(remindersPayload).toEqual({ threadId, remindersCount: 2 }); - - await closeClient(client); - gateway.onModuleDestroy(); - (gateway as unknown as { io?: { close(): void } }).io?.close(); - await closeServer(server); - }); - - it('publishes run status changes to thread and run subscribers', async () => { - const runtime = createRuntimeStub(); - const metricsDouble = createMetricsDouble(); - const prismaService = ({ getClient: () => prisma }) as PrismaService; - const runEvents = new RunEventsService(prismaService); - const eventsBus = new EventsBusService(runEvents); - const gateway = new GraphSocketGateway(runtime, metricsDouble.service, prismaService, eventsBus); - gateway.onModuleInit(); - - const server = createServer(); - await new Promise((resolve) => server.listen(0, resolve)); - const { port } = server.address() as AddressInfo; - gateway.init({ server }); - - const threadClient = createClient(`http://127.0.0.1:${port}`, { path: '/socket.io', transports: ['websocket'] }); - await new Promise((resolve, reject) => { - threadClient.once('connect', resolve); - threadClient.once('connect_error', reject); - }); - - const thread = await prisma.thread.create({ data: { alias: `thread-${randomUUID()}`, summary: 'initial' } }); - await subscribeRooms(threadClient, [`thread:${thread.id}`]); - - const templateRegistryStub = ({ getMeta: () => undefined }) as unknown as TemplateRegistry; - const graphRepositoryStub = ({ get: async () => ({ nodes: [] }) }) as unknown as GraphRepository; - const agents = new AgentsPersistenceService( - prismaService, - metricsDouble.service, - templateRegistryStub, - graphRepositoryStub, - runEvents, - createLinkingStub(), - eventsBus, - ); - - const startResult = await agents.beginRunThread(thread.id, [HumanMessage.fromText('hello')]); - const runId = startResult.runId; - - const runClient = createClient(`http://127.0.0.1:${port}`, { path: '/socket.io', transports: ['websocket'] }); - await new Promise((resolve, reject) => { - runClient.once('connect', resolve); - runClient.once('connect_error', reject); - }); - await subscribeRooms(runClient, [`run:${runId}`]); - - const statusFromThread = waitForEvent<{ run: { id: string; status: string } }>(threadClient, 'run_status_changed'); - const statusFromRun = waitForEvent<{ run: { id: string; status: string } }>(runClient, 'run_status_changed'); - - await agents.completeRun(runId, 'finished', [AIMessage.fromText('done')]); - - const [threadEvent, runEvent] = await Promise.all([statusFromThread, statusFromRun]); - expect(threadEvent.run.status).toBe('finished'); - expect(runEvent.run.id).toBe(runId); - - await new Promise((resolve) => setTimeout(resolve, 150)); - - await prisma.thread.delete({ where: { id: thread.id } }); - - await Promise.all([closeClient(runClient), closeClient(threadClient)]); - gateway.onModuleDestroy(); - (gateway as unknown as { io?: { close(): void } }).io?.close(); - await closeServer(server); - }); - - it('publishes run timeline append and update events with reconciled payloads', async () => { - const runtime = createRuntimeStub(); - const metricsDouble = createMetricsDouble(); - const prismaService = ({ getClient: () => prisma }) as PrismaService; - const runEvents = new RunEventsService(prismaService); - const eventsBus = new EventsBusService(runEvents); - const gateway = new GraphSocketGateway(runtime, metricsDouble.service, prismaService, eventsBus); - gateway.onModuleInit(); - - const server = createServer(); - await new Promise((resolve) => server.listen(0, resolve)); - const { port } = server.address() as AddressInfo; - gateway.init({ server }); - - const templateRegistryStub = ({ getMeta: () => undefined }) as unknown as TemplateRegistry; - const graphRepositoryStub = ({ get: async () => ({ nodes: [] }) }) as unknown as GraphRepository; - const agents = new AgentsPersistenceService( - prismaService, - metricsDouble.service, - templateRegistryStub, - graphRepositoryStub, - runEvents, - createLinkingStub(), - eventsBus, - ); - - const thread = await prisma.thread.create({ data: { alias: `thread-${randomUUID()}`, summary: 'timeline' } }); - const startResult = await agents.beginRunThread(thread.id, [HumanMessage.fromText('start')]); - const runId = startResult.runId; - - const threadClient = createClient(`http://127.0.0.1:${port}`, { path: '/socket.io', transports: ['websocket'] }); - await new Promise((resolve, reject) => { - threadClient.once('connect', resolve); - threadClient.once('connect_error', reject); - }); - await subscribeRooms(threadClient, [`thread:${thread.id}`]); - - const runClient = createClient(`http://127.0.0.1:${port}`, { path: '/socket.io', transports: ['websocket'] }); - await new Promise((resolve, reject) => { - runClient.once('connect', resolve); - runClient.once('connect_error', reject); - }); - await subscribeRooms(runClient, [`run:${runId}`]); - - const toolExecution = await runEvents.startToolExecution({ - runId, - threadId: thread.id, - toolName: 'search', - toolCallId: 'call-1', - input: { query: 'status' }, - }); - - const appendThreadEvent = waitForEvent<{ mutation: string; event: { id: string } }>(threadClient, 'run_event_appended'); - const appendRunEvent = waitForEvent<{ mutation: string; event: { id: string } }>(runClient, 'run_event_appended'); - const appendPayload = await eventsBus.publishEvent(toolExecution.id, 'append'); - expect(appendPayload?.toolExecution?.input).toEqual({ query: 'status' }); - const [appendThread, appendRun] = await Promise.all([appendThreadEvent, appendRunEvent]); - expect(appendThread.mutation).toBe('append'); - expect(appendRun.event.id).toBe(toolExecution.id); - - await runEvents.completeToolExecution({ - eventId: toolExecution.id, - status: ToolExecStatus.success, - output: { answer: 42 }, - raw: { latencyMs: 1200 }, - }); - - const updateThreadEvent = waitForEvent<{ mutation: string; event: { toolExecution?: { output?: unknown } } }>(threadClient, 'run_event_updated'); - const updateRunEvent = waitForEvent<{ mutation: string; event: { toolExecution?: { output?: unknown } } }>(runClient, 'run_event_updated'); - await eventsBus.publishEvent(toolExecution.id, 'update'); - const [updateThread, updateRun] = await Promise.all([updateThreadEvent, updateRunEvent]); - expect(updateThread.mutation).toBe('update'); - expect(updateRun.event.toolExecution?.output).toEqual({ answer: 42 }); - - await new Promise((resolve) => setTimeout(resolve, 150)); - - await prisma.thread.delete({ where: { id: thread.id } }); - - await Promise.all([closeClient(runClient), closeClient(threadClient)]); - gateway.onModuleDestroy(); - (gateway as unknown as { io?: { close(): void } }).io?.close(); - await closeServer(server); - }); - }); -} diff --git a/packages/platform-server/__tests__/sql.threads.metrics.queries.test.ts b/packages/platform-server/__tests__/sql.threads.metrics.queries.test.ts index 1b9e81b59..e916ea984 100644 --- a/packages/platform-server/__tests__/sql.threads.metrics.queries.test.ts +++ b/packages/platform-server/__tests__/sql.threads.metrics.queries.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; -import { GraphSocketGateway } from '../src/gateway/graph.socket.gateway'; import type { PrismaService } from '../src/core/services/prisma.service'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; describe('SQL: WITH RECURSIVE and UUID casts', () => { it('getThreadsMetrics uses WITH RECURSIVE and ::uuid[] and returns expected aggregation', async () => { @@ -64,16 +64,17 @@ describe('SQL: WITH RECURSIVE and UUID casts', () => { const metricsStub = { getThreadsMetrics: vi.fn(async () => ({})) }; const runtimeStub = { subscribe: () => () => {} } as any; const eventsBusStub = {} as any; - const gateway = new GraphSocketGateway(runtimeStub, metricsStub as any, prismaStub, eventsBusStub); + const brokerStub = { connect: vi.fn(), publish: vi.fn(), close: vi.fn() }; + const publisher = new NotificationsPublisher(runtimeStub, metricsStub as any, prismaStub, eventsBusStub, brokerStub as any); const scheduled: string[] = []; // Spy/override scheduleThreadMetrics to capture scheduled ids - type SchedFn = GraphSocketGateway['scheduleThreadMetrics']; + type SchedFn = NotificationsPublisher['scheduleThreadMetrics']; const override = ((id: string) => { scheduled.push(id); }) satisfies SchedFn; // Assign using bracket notation to avoid broad casts - (gateway as any)['scheduleThreadMetrics'] = override; + (publisher as any)['scheduleThreadMetrics'] = override; - await gateway.scheduleThreadAndAncestorsMetrics(leaf); + await publisher.scheduleThreadAndAncestorsMetrics(leaf); expect(captured.length).toBe(1); const call = captured[0]; diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 669058e5e..24a4ec969 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -60,7 +60,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "semver": "^7.6.3", - "socket.io": "^4.8.1", + "ioredis": "^5.4.1", "tar-stream": "^3.1.7", "undici": "^6.19.8", "uuid": "^13.0.0", @@ -84,7 +84,6 @@ "nock": "^14.0.10", "prettier": "^3.6.2", "prisma": "^6.18.0", - "socket.io-client": "^4.8.1", "ts-node": "^10.0.0", "tsx": "^4.20.5", "type-fest": "^4.41.0", diff --git a/packages/platform-server/src/bootstrap/app.module.ts b/packages/platform-server/src/bootstrap/app.module.ts index 5a7f243b4..7568556e4 100644 --- a/packages/platform-server/src/bootstrap/app.module.ts +++ b/packages/platform-server/src/bootstrap/app.module.ts @@ -3,7 +3,7 @@ import { LoggerModule } from 'nestjs-pino'; import { CoreModule } from '../core/core.module'; import { EventsModule } from '../events/events.module'; import { GraphApiModule } from '../graph/graph-api.module'; -import { GatewayModule } from '../gateway/gateway.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { InfraModule } from '../infra/infra.module'; import { StartupRecoveryService } from '../core/services/startupRecovery.service'; import { NodesModule } from '../nodes/nodes.module'; @@ -59,7 +59,7 @@ const createLoggerModule = (): DynamicModule => { InfraModule, GraphApiModule, NodesModule, - GatewayModule, + NotificationsModule, UserProfileModule, OnboardingModule, LLMSettingsModule, diff --git a/packages/platform-server/src/core/services/config.service.ts b/packages/platform-server/src/core/services/config.service.ts index d6080b05e..4f51a48f4 100644 --- a/packages/platform-server/src/core/services/config.service.ts +++ b/packages/platform-server/src/core/services/config.service.ts @@ -67,6 +67,15 @@ export const configSchema = z.object({ const num = typeof v === 'number' ? v : Number(v); return Number.isFinite(num) ? num : 30_000; }), + notificationsRedisUrl: z + .string() + .min(1, 'NOTIFICATIONS_REDIS_URL is required') + .transform((value) => value.trim()), + notificationsChannel: z + .string() + .min(1) + .default('notifications.v1') + .transform((value) => value.trim()), // Workspace container network name workspaceNetworkName: z.string().min(1).default('agents_net'), // Nix search/proxy settings @@ -325,6 +334,14 @@ export class ConfigService implements Config { return this.params.dockerRunnerTimeoutMs; } + get notificationsRedisUrl(): string { + return this.params.notificationsRedisUrl; + } + + get notificationsChannel(): string { + return this.params.notificationsChannel; + } + getDockerRunnerBaseUrl(): string { return this.dockerRunnerBaseUrl; } @@ -446,6 +463,8 @@ export class ConfigService implements Config { dockerRunnerBaseUrl: process.env.DOCKER_RUNNER_BASE_URL, dockerRunnerSharedSecret: process.env.DOCKER_RUNNER_SHARED_SECRET, dockerRunnerTimeoutMs: process.env.DOCKER_RUNNER_TIMEOUT_MS, + notificationsRedisUrl: process.env.NOTIFICATIONS_REDIS_URL, + notificationsChannel: process.env.NOTIFICATIONS_CHANNEL, workspaceNetworkName: process.env.WORKSPACE_NETWORK_NAME, nixAllowedChannels: process.env.NIX_ALLOWED_CHANNELS, nixHttpTimeoutMs: process.env.NIX_HTTP_TIMEOUT_MS, diff --git a/packages/platform-server/src/gateway/gateway.module.ts b/packages/platform-server/src/gateway/gateway.module.ts deleted file mode 100644 index 57526a09a..000000000 --- a/packages/platform-server/src/gateway/gateway.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GraphApiModule } from '../graph/graph-api.module'; -import { EventsModule } from '../events/events.module'; -import { GraphSocketGateway } from './graph.socket.gateway'; - -@Module({ - imports: [GraphApiModule, EventsModule], - providers: [GraphSocketGateway], - exports: [GraphSocketGateway], -}) -export class GatewayModule {} diff --git a/packages/platform-server/src/gateway/graph.socket.gateway.ts b/packages/platform-server/src/gateway/graph.socket.gateway.ts deleted file mode 100644 index 8eb04c8b8..000000000 --- a/packages/platform-server/src/gateway/graph.socket.gateway.ts +++ /dev/null @@ -1,719 +0,0 @@ -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Scope } from '@nestjs/common'; -import type { IncomingHttpHeaders, Server as HTTPServer } from 'http'; -import { Server as SocketIOServer, type ServerOptions, type Socket } from 'socket.io'; -import { z } from 'zod'; -import { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; -import type { ThreadStatus, MessageKind, RunStatus } from '@prisma/client'; -import { - EventsBusService, - type MessageBroadcast, - type NodeStateBusEvent, - type ReminderCountEvent as ReminderCountBusEvent, - type RunEventBroadcast, - type RunEventBusPayload, - type RunStatusBroadcast, - type ThreadBroadcast, - type ThreadMetricsAncestorsEvent, - type ThreadMetricsEvent, -} from '../events/events-bus.service'; -import type { ToolOutputChunkPayload, ToolOutputTerminalPayload } from '../events/run-events.service'; -import { ThreadsMetricsService } from '../agents/threads.metrics.service'; -import { PrismaService } from '../core/services/prisma.service'; - -// Strict outbound event payloads -export const NodeStatusEventSchema = z - .object({ - nodeId: z.string(), - provisionStatus: z - .object({ - state: z.enum([ - 'not_ready', - 'provisioning', - 'ready', - 'deprovisioning', - 'provisioning_error', - 'deprovisioning_error', - ]), - details: z.unknown().optional(), - }) - .partial(), - updatedAt: z.string().datetime().optional(), - }) - .strict(); -export type NodeStatusEvent = z.infer; - -export const NodeStateEventSchema = z - .object({ - nodeId: z.string(), - state: z.record(z.string(), z.unknown()), - updatedAt: z.string().datetime(), - }) - .strict(); -export type NodeStateEvent = z.infer; - -// RemindMe: active reminder count event -export const ReminderCountSocketEventSchema = z - .object({ - nodeId: z.string(), - count: z.number().int().min(0), - updatedAt: z.string().datetime(), - }) - .strict(); -export type ReminderCountSocketEvent = z.infer; - -export const ToolOutputChunkEventSchema = z - .object({ - runId: z.string().uuid(), - threadId: z.string().uuid(), - eventId: z.string().uuid(), - seqGlobal: z.number().int().positive(), - seqStream: z.number().int().positive(), - source: z.enum(['stdout', 'stderr']), - ts: z.string().datetime(), - data: z.string(), - }) - .strict(); -export type ToolOutputChunkEvent = z.infer; - -export const ToolOutputTerminalEventSchema = z - .object({ - runId: z.string().uuid(), - threadId: z.string().uuid(), - eventId: z.string().uuid(), - exitCode: z.number().int().nullable(), - status: z.enum(['success', 'error', 'timeout', 'idle_timeout', 'cancelled', 'truncated']), - bytesStdout: z.number().int().min(0), - bytesStderr: z.number().int().min(0), - totalChunks: z.number().int().min(0), - droppedChunks: z.number().int().min(0), - savedPath: z.string().optional().nullable(), - message: z.string().optional().nullable(), - ts: z.string().datetime(), - }) - .strict(); -export type ToolOutputTerminalEvent = z.infer; -/** - * Socket.IO gateway attached to Fastify/Nest HTTP server for graph events. - * Constructors DI-only; call init({ server }) explicitly from bootstrap. - */ -function toDate(value: string): Date | null { - const ts = new Date(value); - return Number.isNaN(ts.getTime()) ? null : ts; -} - -@Injectable({ scope: Scope.DEFAULT }) -export class GraphSocketGateway implements OnModuleInit, OnModuleDestroy { - private readonly logger = new Logger(GraphSocketGateway.name); - private io: SocketIOServer | null = null; - private initialized = false; - private pendingThreads = new Set(); - private metricsTimer: NodeJS.Timeout | null = null; - private readonly COALESCE_MS = 100; - private readonly cleanup: Array<() => void> = []; - - constructor( - @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, - @Inject(ThreadsMetricsService) private readonly metrics: ThreadsMetricsService, - @Inject(PrismaService) private readonly prismaService: PrismaService, - @Inject(EventsBusService) private readonly eventsBus: EventsBusService, - ) {} - - onModuleInit(): void { - this.cleanup.push(this.eventsBus.subscribeToRunEvents(this.handleRunEvent)); - this.cleanup.push(this.eventsBus.subscribeToToolOutputChunk(this.handleToolOutputChunk)); - this.cleanup.push(this.eventsBus.subscribeToToolOutputTerminal(this.handleToolOutputTerminal)); - this.cleanup.push(this.eventsBus.subscribeToReminderCount(this.handleReminderCount)); - this.cleanup.push(this.eventsBus.subscribeToNodeState(this.handleNodeState)); - this.cleanup.push(this.eventsBus.subscribeToThreadCreated(this.handleThreadCreated)); - this.cleanup.push(this.eventsBus.subscribeToThreadUpdated(this.handleThreadUpdated)); - this.cleanup.push(this.eventsBus.subscribeToMessageCreated(this.handleMessageCreated)); - this.cleanup.push(this.eventsBus.subscribeToRunStatusChanged(this.handleRunStatusChanged)); - this.cleanup.push(this.eventsBus.subscribeToThreadMetrics(this.handleThreadMetrics)); - this.cleanup.push(this.eventsBus.subscribeToThreadMetricsAncestors(this.handleThreadMetricsAncestors)); - } - - onModuleDestroy(): void { - for (const dispose of this.cleanup.splice(0)) { - try { - dispose(); - } catch (err) { - this.logger.warn( - `GraphSocketGateway: cleanup failed${this.formatContext({ error: this.toSafeError(err) })}`, - ); - } - } - } - - /** Attach Socket.IO to the provided HTTP server. */ - init(params: { server: HTTPServer }): this { - if (this.initialized) return this; - const server = params.server; - const options: Partial = { - path: '/socket.io', - transports: ['websocket'] as ServerOptions['transports'], - cors: { origin: '*' }, - allowRequest: (_req, callback) => { - callback(null, true); - }, - }; - this.io = new SocketIOServer(server, options); - this.io.on('connection', (socket: Socket) => { - // Room subscription - const RoomSchema = z.union([ - z.literal('threads'), - z.literal('graph'), - z.string().regex(/^thread:[0-9a-z-]{1,64}$/i), - z.string().regex(/^run:[0-9a-z-]{1,64}$/i), - z.string().regex(/^node:[0-9a-z-]{1,64}$/i), - ]); - const SubscribeSchema = z - .object({ rooms: z.array(RoomSchema).optional(), room: RoomSchema.optional() }) - .strict(); - socket.on('subscribe', (payload: unknown, ack?: (response: unknown) => void) => { - const parsed = SubscribeSchema.safeParse(payload); - if (!parsed.success) { - const details = parsed.error.issues.map((issue) => ({ - path: issue.path, - message: issue.message, - code: issue.code, - })); - this.logger.warn( - `GraphSocketGateway: subscribe invalid${this.formatContext({ socketId: socket.id, issues: details })}`, - ); - if (typeof ack === 'function') { - ack({ ok: false, error: 'invalid_payload', issues: details }); - } - return; - } - const p = parsed.data; - const rooms: string[] = p.rooms ?? (p.room ? [p.room] : []); - for (const r of rooms) if (r.length > 0) socket.join(r); - if (typeof ack === 'function') { - ack({ ok: true, rooms }); - } - }); - socket.on('error', (e: unknown) => { - this.logger.warn( - `GraphSocketGateway: socket error${this.formatContext({ - socketId: socket.id, - error: this.toSafeError(e), - })}`, - ); - }); - }); - this.initialized = true; - // Wire runtime status events to socket broadcast - this.attachRuntimeSubscriptions(); - return this; - } - - private readonly handleRunEvent = (payload: RunEventBusPayload): void => { - const event = payload.event; - if (!event) { - this.logger.warn( - `GraphSocketGateway received run event payload without snapshot${this.formatContext({ - eventId: payload.eventId, - mutation: payload.mutation, - })}`, - ); - return; - } - try { - const broadcast: RunEventBroadcast = { - runId: event.runId, - mutation: payload.mutation, - event, - }; - this.emitRunEvent(event.runId, event.threadId, broadcast); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit run event${this.formatContext({ - eventId: payload.eventId, - mutation: payload.mutation, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleToolOutputChunk = (payload: ToolOutputChunkPayload): void => { - const ts = toDate(payload.ts); - if (!ts) { - this.logger.warn( - `GraphSocketGateway received invalid chunk timestamp${this.formatContext({ - eventId: payload.eventId, - ts: payload.ts, - })}`, - ); - return; - } - try { - this.emitToolOutputChunk({ - runId: payload.runId, - threadId: payload.threadId, - eventId: payload.eventId, - seqGlobal: payload.seqGlobal, - seqStream: payload.seqStream, - source: payload.source, - ts, - data: payload.data, - }); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit tool_output_chunk${this.formatContext({ - eventId: payload.eventId, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleToolOutputTerminal = (payload: ToolOutputTerminalPayload): void => { - const ts = toDate(payload.ts); - if (!ts) { - this.logger.warn( - `GraphSocketGateway received invalid terminal timestamp${this.formatContext({ - eventId: payload.eventId, - ts: payload.ts, - })}`, - ); - return; - } - try { - this.emitToolOutputTerminal({ - runId: payload.runId, - threadId: payload.threadId, - eventId: payload.eventId, - exitCode: payload.exitCode, - status: payload.status, - bytesStdout: payload.bytesStdout, - bytesStderr: payload.bytesStderr, - totalChunks: payload.totalChunks, - droppedChunks: payload.droppedChunks, - savedPath: payload.savedPath ?? undefined, - message: payload.message ?? undefined, - ts, - }); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit tool_output_terminal${this.formatContext({ - eventId: payload.eventId, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleReminderCount = (payload: ReminderCountBusEvent): void => { - try { - this.emitReminderCount(payload.nodeId, payload.count, payload.updatedAtMs); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit reminder_count${this.formatContext({ - nodeId: payload.nodeId, - error: this.toSafeError(err), - })}`, - ); - return; - } - - const threadId = payload.threadId; - if (!threadId) return; - - let scheduleResult: void | Promise; - try { - scheduleResult = this.scheduleThreadAndAncestorsMetrics(threadId); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to schedule metrics from reminder count${this.formatContext({ - nodeId: payload.nodeId, - threadId, - error: this.toSafeError(err), - })}`, - ); - return; - } - - void Promise.resolve(scheduleResult).catch((err) => { - this.logger.warn( - `GraphSocketGateway failed to schedule metrics from reminder count${this.formatContext({ - nodeId: payload.nodeId, - threadId, - error: this.toSafeError(err), - })}`, - ); - }); - }; - - private readonly handleNodeState = (payload: NodeStateBusEvent): void => { - try { - this.emitNodeState(payload.nodeId, payload.state, payload.updatedAtMs); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit node_state${this.formatContext({ - nodeId: payload.nodeId, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleThreadCreated = (thread: ThreadBroadcast): void => { - try { - this.emitThreadCreated(thread); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit thread_created${this.formatContext({ - threadId: thread.id, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleThreadUpdated = (thread: ThreadBroadcast): void => { - try { - this.emitThreadUpdated(thread); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit thread_updated${this.formatContext({ - threadId: thread.id, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleMessageCreated = (payload: { threadId: string; message: MessageBroadcast }): void => { - try { - this.logger.log( - `new message${this.formatContext({ - threadId: payload.threadId, - messageId: payload.message.id, - kind: payload.message.kind, - runId: payload.message.runId ?? null, - })}`, - ); - this.emitMessageCreated(payload.threadId, payload.message); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit message_created${this.formatContext({ - threadId: payload.threadId, - messageId: payload.message.id, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleRunStatusChanged = (payload: RunStatusBroadcast): void => { - try { - this.emitRunStatusChanged(payload.threadId, payload.run); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to emit run_status_changed${this.formatContext({ - threadId: payload.threadId, - runId: payload.run.id, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleThreadMetrics = (payload: ThreadMetricsEvent): void => { - try { - this.scheduleThreadMetrics(payload.threadId); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to schedule thread metrics${this.formatContext({ - threadId: payload.threadId, - error: this.toSafeError(err), - })}`, - ); - } - }; - - private readonly handleThreadMetricsAncestors = (payload: ThreadMetricsAncestorsEvent): void => { - let scheduleResult: void | Promise; - try { - scheduleResult = this.scheduleThreadAndAncestorsMetrics(payload.threadId); - } catch (err) { - this.logger.warn( - `GraphSocketGateway failed to schedule ancestor thread metrics${this.formatContext({ - threadId: payload.threadId, - error: this.toSafeError(err), - })}`, - ); - return; - } - - void Promise.resolve(scheduleResult).catch((err) => { - this.logger.warn( - `GraphSocketGateway failed to schedule ancestor thread metrics${this.formatContext({ - threadId: payload.threadId, - error: this.toSafeError(err), - })}`, - ); - }); - }; - - private broadcast( - event: 'node_status' | 'node_state' | 'node_reminder_count', - payload: T, - schema: z.ZodType, - ) { - if (!this.io) return; - const parsed = schema.safeParse(payload); - if (!parsed.success) { - this.logger.error( - `Gateway payload validation failed${this.formatContext({ issues: parsed.error.issues })}`, - ); - return; - } - const data = parsed.data; - this.emitToRooms(['graph', `node:${data.nodeId}`], event, data); - } - - private attachRuntimeSubscriptions() { - // Subscribe via runtime forwarder - this.runtime.subscribe((ev) => { - const payload: NodeStatusEvent = { - nodeId: ev.nodeId, - provisionStatus: { state: ev.next as NodeStatusEvent['provisionStatus']['state'] }, - updatedAt: new Date(ev.at).toISOString(), - }; - this.broadcast('node_status', payload, NodeStatusEventSchema); - }); - } - - // Note: node-level subscription handled via runtime.subscribe() - - /** Emit node_state event when NodeStateService updates runtime snapshot. Public for DI bridge usage. */ - emitNodeState(nodeId: string, state: Record, updatedAtMs?: number): void { - const payload: NodeStateEvent = { - nodeId, - state, - updatedAt: new Date(updatedAtMs ?? Date.now()).toISOString(), - }; - this.broadcast('node_state', payload, NodeStateEventSchema); - } - /** Emit node_reminder_count event for RemindMe tool nodes when registry changes. */ - emitReminderCount(nodeId: string, count: number, updatedAtMs?: number): void { - const payload: ReminderCountSocketEvent = { - nodeId, - count, - updatedAt: new Date(updatedAtMs ?? Date.now()).toISOString(), - }; - this.broadcast('node_reminder_count', payload, ReminderCountSocketEventSchema); - } - - // Threads realtime events - emitThreadCreated(thread: { - id: string; - alias: string; - summary: string | null; - status: ThreadStatus; - createdAt: Date; - parentId?: string | null; - channelNodeId?: string | null; - }) { - const payload = { thread: { ...thread, createdAt: thread.createdAt.toISOString() } }; - this.emitToRooms(['threads'], 'thread_created', payload); - } - emitThreadUpdated(thread: { - id: string; - alias: string; - summary: string | null; - status: ThreadStatus; - createdAt: Date; - parentId?: string | null; - channelNodeId?: string | null; - }) { - const payload = { thread: { ...thread, createdAt: thread.createdAt.toISOString() } }; - this.emitToRooms(['threads'], 'thread_updated', payload); - } - emitMessageCreated(threadId: string, message: { id: string; kind: MessageKind; text: string | null; source: import('type-fest').JsonValue | unknown; createdAt: Date; runId?: string }) { - const payload = { threadId, message: { ...message, createdAt: message.createdAt.toISOString() } }; - this.emitToRooms([`thread:${threadId}`], 'message_created', payload); - } - emitRunStatusChanged(threadId: string, run: { id: string; status: RunStatus; createdAt: Date; updatedAt: Date }) { - const payload = { - threadId, - run: { - ...run, - threadId, - createdAt: run.createdAt.toISOString(), - updatedAt: run.updatedAt.toISOString(), - }, - }; - this.emitToRooms([`thread:${threadId}`, `run:${run.id}`], 'run_status_changed', payload); - } - emitRunEvent(runId: string, threadId: string, payload: RunEventBroadcast) { - const eventName = payload.mutation === 'update' ? 'run_event_updated' : 'run_event_appended'; - this.emitToRooms([`run:${runId}`, `thread:${threadId}`], eventName, payload); - } - emitToolOutputChunk(payload: { - runId: string; - threadId: string; - eventId: string; - seqGlobal: number; - seqStream: number; - source: 'stdout' | 'stderr'; - ts: Date; - data: string; - }) { - const eventPayload: ToolOutputChunkEvent = { - runId: payload.runId, - threadId: payload.threadId, - eventId: payload.eventId, - seqGlobal: payload.seqGlobal, - seqStream: payload.seqStream, - source: payload.source, - ts: payload.ts.toISOString(), - data: payload.data, - }; - const parsed = ToolOutputChunkEventSchema.safeParse(eventPayload); - if (!parsed.success) { - this.logger.error( - `Gateway payload validation failed for tool_output_chunk${this.formatContext({ issues: parsed.error.issues })}`, - ); - return; - } - this.emitToRooms([`run:${eventPayload.runId}`, `thread:${eventPayload.threadId}`], 'tool_output_chunk', eventPayload); - } - emitToolOutputTerminal(payload: { - runId: string; - threadId: string; - eventId: string; - exitCode: number | null; - status: 'success' | 'error' | 'timeout' | 'idle_timeout' | 'cancelled' | 'truncated'; - bytesStdout: number; - bytesStderr: number; - totalChunks: number; - droppedChunks: number; - savedPath?: string | null; - message?: string | null; - ts: Date; - }) { - const eventPayload: ToolOutputTerminalEvent = { - runId: payload.runId, - threadId: payload.threadId, - eventId: payload.eventId, - exitCode: payload.exitCode, - status: payload.status, - bytesStdout: payload.bytesStdout, - bytesStderr: payload.bytesStderr, - totalChunks: payload.totalChunks, - droppedChunks: payload.droppedChunks, - savedPath: payload.savedPath ?? null, - message: payload.message ?? null, - ts: payload.ts.toISOString(), - }; - const parsed = ToolOutputTerminalEventSchema.safeParse(eventPayload); - if (!parsed.success) { - this.logger.error( - `Gateway payload validation failed for tool_output_terminal${this.formatContext({ issues: parsed.error.issues })}`, - ); - return; - } - this.emitToRooms([`run:${eventPayload.runId}`, `thread:${eventPayload.threadId}`], 'tool_output_terminal', eventPayload); - } - private flushMetricsQueue = async () => { - // De-duplicate pending thread IDs per flush (preserve insertion order) - const ids = Array.from(new Set(this.pendingThreads)); - this.pendingThreads.clear(); - this.metricsTimer = null; - if (!this.io || ids.length === 0) return; - try { - const map = await this.metrics.getThreadsMetrics(ids); - for (const id of ids) { - const m = map[id]; - if (!m) continue; - const activityPayload = { threadId: id, activity: m.activity }; - this.emitToRooms(['threads', `thread:${id}`], 'thread_activity_changed', activityPayload); - const remindersPayload = { threadId: id, remindersCount: m.remindersCount }; - this.emitToRooms(['threads', `thread:${id}`], 'thread_reminders_count', remindersPayload); - } - } catch (e) { - this.logger.error(`flushMetricsQueue error${this.formatContext({ error: this.toSafeError(e) })}`); - } - }; - scheduleThreadMetrics(threadId: string) { - this.pendingThreads.add(threadId); - if (!this.metricsTimer) this.metricsTimer = setTimeout(this.flushMetricsQueue, this.COALESCE_MS); - } - async scheduleThreadAndAncestorsMetrics(threadId: string) { - try { - const prisma = this.prismaService.getClient(); - const rows: Array<{ id: string; parentId: string | null }> = await prisma.$queryRaw>` - with recursive rec as ( - select t.id, t."parentId" from "Thread" t where t.id = ${threadId}::uuid - union all - select p.id, p."parentId" from "Thread" p join rec r on r."parentId" = p.id - ) - select id, "parentId" from rec; - `; - for (const r of rows) this.scheduleThreadMetrics(r.id); - } catch (e) { - this.logger.error( - `scheduleThreadAndAncestorsMetrics error${this.formatContext({ error: this.toSafeError(e) })}`, - ); - this.scheduleThreadMetrics(threadId); - } - } - - private sanitizeHeaders(headers: IncomingHttpHeaders | undefined): Record { - if (!headers) return {}; - const sensitive = new Set(['authorization', 'cookie', 'set-cookie']); - const sanitized: Record = {}; - for (const [key, value] of Object.entries(headers)) { - if (!key) continue; - sanitized[key] = sensitive.has(key.toLowerCase()) ? '[REDACTED]' : value; - } - return sanitized; - } - - private sanitizeQuery(query: Record | undefined): Record { - if (!query) return {}; - const sensitive = new Set(['token', 'authorization', 'auth', 'api_key', 'access_token']); - const sanitized: Record = {}; - for (const [key, value] of Object.entries(query)) { - sanitized[key] = key && sensitive.has(key.toLowerCase()) ? '[REDACTED]' : value; - } - return sanitized; - } - - private emitToRooms( - rooms: string[], - event: string, - payload: unknown, - ) { - if (!this.io || rooms.length === 0) return; - for (const room of rooms) { - try { - this.io.to(room).emit(event, payload); - } catch (error) { - const errPayload = - error instanceof Error ? { name: error.name, message: error.message } : { message: String(error) }; - this.logger.warn( - `GraphSocketGateway: emit error ${this.formatContext({ event, room, error: errPayload })}`, - ); - } - } - } - - private formatContext(context: Record): string { - return ` ${JSON.stringify(context)}`; - } - - private toSafeError(error: unknown): { name?: string; message: string } { - if (error instanceof Error) { - return { name: error.name, message: error.message }; - } - try { - return { message: JSON.stringify(error) }; - } catch { - return { message: String(error) }; - } - } -} diff --git a/packages/platform-server/src/index.ts b/packages/platform-server/src/index.ts index 06398476c..b23da5245 100644 --- a/packages/platform-server/src/index.ts +++ b/packages/platform-server/src/index.ts @@ -17,7 +17,6 @@ import { Logger as PinoLogger } from 'nestjs-pino'; import { AppModule } from './bootstrap/app.module'; import { ConfigService } from './core/services/config.service'; -import { GraphSocketGateway } from './gateway/graph.socket.gateway'; import { LiveGraphRuntime } from './graph'; import { ContainerTerminalGateway } from './infra/container/terminal.gateway'; @@ -73,10 +72,6 @@ async function bootstrap() { const terminalGateway = app.get(ContainerTerminalGateway); terminalGateway.registerRoutes(fastifyInstance); - // Attach Socket.IO gateway via DI before starting server - const gateway = app.get(GraphSocketGateway); - gateway.init({ server: fastifyInstance.server }); - // Start Fastify HTTP server const PORT = Number(process.env.PORT) || 3010; await fastifyInstance.listen({ port: PORT, host: '0.0.0.0' }); diff --git a/packages/platform-server/src/notifications/notifications.broker.ts b/packages/platform-server/src/notifications/notifications.broker.ts new file mode 100644 index 000000000..86fd4b028 --- /dev/null +++ b/packages/platform-server/src/notifications/notifications.broker.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { ConfigService } from '../core/services/config.service'; + +@Injectable() +export class NotificationsBroker { + private readonly redis: Redis; + private readonly channel: string; + private connected = false; + + constructor(private readonly config: ConfigService) { + this.channel = config.notificationsChannel; + this.redis = new Redis(config.notificationsRedisUrl, { + lazyConnect: true, + maxRetriesPerRequest: null, + enableReadyCheck: true, + }); + } + + async connect(): Promise { + if (this.connected) return; + await this.redis.connect(); + this.connected = true; + } + + async publish(envelope: NotificationEnvelope): Promise { + if (!this.connected) { + await this.connect(); + } + await this.redis.publish(this.channel, JSON.stringify(envelope)); + } + + async close(): Promise { + if (!this.connected) return; + this.connected = false; + await this.redis.quit(); + } +} diff --git a/packages/platform-server/src/notifications/notifications.module.ts b/packages/platform-server/src/notifications/notifications.module.ts new file mode 100644 index 000000000..eb05549ef --- /dev/null +++ b/packages/platform-server/src/notifications/notifications.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { GraphApiModule } from '../graph/graph-api.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsPublisher } from './notifications.publisher'; +import { NotificationsBroker } from './notifications.broker'; + +@Module({ + imports: [GraphApiModule, EventsModule], + providers: [NotificationsPublisher, NotificationsBroker], +}) +export class NotificationsModule {} diff --git a/packages/platform-server/src/notifications/notifications.publisher.ts b/packages/platform-server/src/notifications/notifications.publisher.ts new file mode 100644 index 000000000..94fbb528a --- /dev/null +++ b/packages/platform-server/src/notifications/notifications.publisher.ts @@ -0,0 +1,584 @@ +import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; +import type { NotificationEnvelope, NotificationRoom } from '@agyn/shared'; +import { z } from 'zod'; +import { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; +import { ThreadsMetricsService } from '../agents/threads.metrics.service'; +import { PrismaService } from '../core/services/prisma.service'; +import { + EventsBusService, + type MessageBroadcast, + type NodeStateBusEvent, + type ReminderCountEvent, + type RunEventBroadcast, + type RunEventBusPayload, + type RunStatusBroadcast, + type ThreadBroadcast, + type ThreadMetricsAncestorsEvent, + type ThreadMetricsEvent, +} from '../events/events-bus.service'; +import type { ToolOutputChunkPayload, ToolOutputTerminalPayload } from '../events/run-events.service'; +import type { MessageKind, RunStatus, ThreadStatus } from '@prisma/client'; +import { NotificationsBroker } from './notifications.broker'; +import { + NodeStateEventSchema, + NodeStatusEventSchema, + ReminderCountSocketEventSchema, + ToolOutputChunkEventSchema, + ToolOutputTerminalEventSchema, + type NodeStateEvent, + type NodeStatusEvent, + type ReminderCountSocketEvent, + type ToolOutputChunkEvent, + type ToolOutputTerminalEvent, +} from './notifications.schemas'; + +@Injectable() +export class NotificationsPublisher implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(NotificationsPublisher.name); + private readonly cleanup: Array<() => void> = []; + private runtimeDispose: (() => void) | null = null; + private pendingThreads = new Set(); + private metricsTimer: NodeJS.Timeout | null = null; + private readonly COALESCE_MS = 100; + + constructor( + @Inject(LiveGraphRuntime) private readonly runtime: LiveGraphRuntime, + @Inject(ThreadsMetricsService) private readonly metrics: ThreadsMetricsService, + @Inject(PrismaService) private readonly prismaService: PrismaService, + @Inject(EventsBusService) private readonly eventsBus: EventsBusService, + private readonly broker: NotificationsBroker, + ) {} + + async onModuleInit(): Promise { + await this.broker.connect(); + this.cleanup.push(this.eventsBus.subscribeToRunEvents(this.handleRunEvent)); + this.cleanup.push(this.eventsBus.subscribeToToolOutputChunk(this.handleToolOutputChunk)); + this.cleanup.push(this.eventsBus.subscribeToToolOutputTerminal(this.handleToolOutputTerminal)); + this.cleanup.push(this.eventsBus.subscribeToReminderCount(this.handleReminderCount)); + this.cleanup.push(this.eventsBus.subscribeToNodeState(this.handleNodeState)); + this.cleanup.push(this.eventsBus.subscribeToThreadCreated(this.handleThreadCreated)); + this.cleanup.push(this.eventsBus.subscribeToThreadUpdated(this.handleThreadUpdated)); + this.cleanup.push(this.eventsBus.subscribeToMessageCreated(this.handleMessageCreated)); + this.cleanup.push(this.eventsBus.subscribeToRunStatusChanged(this.handleRunStatusChanged)); + this.cleanup.push(this.eventsBus.subscribeToThreadMetrics(this.handleThreadMetrics)); + this.cleanup.push(this.eventsBus.subscribeToThreadMetricsAncestors(this.handleThreadMetricsAncestors)); + this.runtimeDispose = this.runtime.subscribe((ev) => { + const payload: NodeStatusEvent = { + nodeId: ev.nodeId, + provisionStatus: { state: ev.next as NodeStatusEvent['provisionStatus']['state'] }, + updatedAt: new Date(ev.at).toISOString(), + }; + this.broadcast('node_status', payload, NodeStatusEventSchema); + }); + } + + async onModuleDestroy(): Promise { + for (const dispose of this.cleanup.splice(0)) { + try { + dispose(); + } catch (error) { + this.logger.warn( + `NotificationsPublisher cleanup failed${this.formatContext({ error: this.toSafeError(error) })}`, + ); + } + } + if (this.runtimeDispose) { + try { + this.runtimeDispose(); + } catch (error) { + this.logger.warn( + `NotificationsPublisher runtime dispose failed${this.formatContext({ error: this.toSafeError(error) })}`, + ); + } + this.runtimeDispose = null; + } + if (this.metricsTimer) { + clearTimeout(this.metricsTimer); + this.metricsTimer = null; + } + await this.broker.close(); + } + + private readonly handleRunEvent = (payload: RunEventBusPayload): void => { + const event = payload.event; + if (!event) { + this.logger.warn( + `NotificationsPublisher run event missing snapshot${this.formatContext({ + eventId: payload.eventId, + mutation: payload.mutation, + })}`, + ); + return; + } + try { + const broadcast: RunEventBroadcast = { + runId: event.runId, + mutation: payload.mutation, + event, + }; + this.emitRunEvent(event.runId, event.threadId, broadcast); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit run event${this.formatContext({ + eventId: payload.eventId, + mutation: payload.mutation, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleToolOutputChunk = (payload: ToolOutputChunkPayload): void => { + const ts = this.toDate(payload.ts); + if (!ts) { + this.logger.warn( + `NotificationsPublisher received invalid chunk timestamp${this.formatContext({ + eventId: payload.eventId, + ts: payload.ts, + })}`, + ); + return; + } + try { + this.emitToolOutputChunk({ + runId: payload.runId, + threadId: payload.threadId, + eventId: payload.eventId, + seqGlobal: payload.seqGlobal, + seqStream: payload.seqStream, + source: payload.source, + ts, + data: payload.data, + }); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit tool_output_chunk${this.formatContext({ + eventId: payload.eventId, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleToolOutputTerminal = (payload: ToolOutputTerminalPayload): void => { + const ts = this.toDate(payload.ts); + if (!ts) { + this.logger.warn( + `NotificationsPublisher received invalid terminal timestamp${this.formatContext({ + eventId: payload.eventId, + ts: payload.ts, + })}`, + ); + return; + } + try { + this.emitToolOutputTerminal({ + runId: payload.runId, + threadId: payload.threadId, + eventId: payload.eventId, + exitCode: payload.exitCode, + status: payload.status, + bytesStdout: payload.bytesStdout, + bytesStderr: payload.bytesStderr, + totalChunks: payload.totalChunks, + droppedChunks: payload.droppedChunks, + savedPath: payload.savedPath ?? undefined, + message: payload.message ?? undefined, + ts, + }); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit tool_output_terminal${this.formatContext({ + eventId: payload.eventId, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleReminderCount = (payload: ReminderCountEvent): void => { + try { + this.emitReminderCount(payload.nodeId, payload.count, payload.updatedAtMs); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit reminder_count${this.formatContext({ + nodeId: payload.nodeId, + error: this.toSafeError(error), + })}`, + ); + return; + } + + const threadId = payload.threadId; + if (!threadId) return; + + let scheduled: void | Promise; + try { + scheduled = this.scheduleThreadAndAncestorsMetrics(threadId); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to schedule metrics from reminder${this.formatContext({ + nodeId: payload.nodeId, + threadId, + error: this.toSafeError(error), + })}`, + ); + return; + } + + void Promise.resolve(scheduled).catch((error) => { + this.logger.warn( + `NotificationsPublisher failed async metrics scheduling${this.formatContext({ + threadId, + error: this.toSafeError(error), + })}`, + ); + }); + }; + + private readonly handleNodeState = (payload: NodeStateBusEvent): void => { + try { + this.emitNodeState(payload.nodeId, payload.state, payload.updatedAtMs); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit node_state${this.formatContext({ + nodeId: payload.nodeId, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleThreadCreated = (thread: ThreadBroadcast): void => { + try { + this.emitThreadCreated(thread); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit thread_created${this.formatContext({ + threadId: thread.id, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleThreadUpdated = (thread: ThreadBroadcast): void => { + try { + this.emitThreadUpdated(thread); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit thread_updated${this.formatContext({ + threadId: thread.id, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleMessageCreated = (payload: { threadId: string; message: MessageBroadcast }): void => { + try { + this.emitMessageCreated(payload.threadId, payload.message); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit message_created${this.formatContext({ + threadId: payload.threadId, + messageId: payload.message.id, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleRunStatusChanged = (payload: RunStatusBroadcast): void => { + try { + this.emitRunStatusChanged(payload.threadId, payload.run); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to emit run_status_changed${this.formatContext({ + threadId: payload.threadId, + runId: payload.run.id, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleThreadMetrics = (payload: ThreadMetricsEvent): void => { + try { + this.scheduleThreadMetrics(payload.threadId); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to schedule thread metrics${this.formatContext({ + threadId: payload.threadId, + error: this.toSafeError(error), + })}`, + ); + } + }; + + private readonly handleThreadMetricsAncestors = (payload: ThreadMetricsAncestorsEvent): void => { + let scheduled: void | Promise; + try { + scheduled = this.scheduleThreadAndAncestorsMetrics(payload.threadId); + } catch (error) { + this.logger.warn( + `NotificationsPublisher failed to schedule ancestor metrics${this.formatContext({ + threadId: payload.threadId, + error: this.toSafeError(error), + })}`, + ); + return; + } + + void Promise.resolve(scheduled).catch((error) => { + this.logger.warn( + `NotificationsPublisher failed async ancestor metrics${this.formatContext({ + threadId: payload.threadId, + error: this.toSafeError(error), + })}`, + ); + }); + }; + + private broadcast(event: string, payload: T, schema: z.ZodType): void { + const parsed = schema.safeParse(payload); + if (!parsed.success) { + this.logger.error( + `NotificationsPublisher payload validation failed${this.formatContext({ issues: parsed.error.issues })}`, + ); + return; + } + const data = parsed.data; + void this.publishToRooms(['graph', `node:${data.nodeId}`], event, data); + } + + private emitNodeState(nodeId: string, state: Record, updatedAtMs?: number): void { + const payload: NodeStateEvent = { + nodeId, + state, + updatedAt: new Date(updatedAtMs ?? Date.now()).toISOString(), + }; + this.broadcast('node_state', payload, NodeStateEventSchema); + } + + private emitReminderCount(nodeId: string, count: number, updatedAtMs?: number): void { + const payload: ReminderCountSocketEvent = { + nodeId, + count, + updatedAt: new Date(updatedAtMs ?? Date.now()).toISOString(), + }; + this.broadcast('node_reminder_count', payload, ReminderCountSocketEventSchema); + } + + private emitThreadCreated(thread: { + id: string; + alias: string; + summary: string | null; + status: ThreadStatus; + createdAt: Date; + parentId?: string | null; + channelNodeId?: string | null; + assignedAgentNodeId?: string | null; + }): void { + const payload = { thread: { ...thread, createdAt: thread.createdAt.toISOString() } }; + void this.publishToRooms(['threads'], 'thread_created', payload); + } + + private emitThreadUpdated(thread: { + id: string; + alias: string; + summary: string | null; + status: ThreadStatus; + createdAt: Date; + parentId?: string | null; + channelNodeId?: string | null; + assignedAgentNodeId?: string | null; + }): void { + const payload = { thread: { ...thread, createdAt: thread.createdAt.toISOString() } }; + void this.publishToRooms(['threads'], 'thread_updated', payload); + } + + private emitMessageCreated(threadId: string, message: { id: string; kind: MessageKind; text: string | null; source: unknown; createdAt: Date; runId?: string }): void { + const payload = { threadId, message: { ...message, createdAt: message.createdAt.toISOString() } }; + void this.publishToRooms([`thread:${threadId}`], 'message_created', payload); + } + + private emitRunStatusChanged(threadId: string, run: { id: string; status: RunStatus; createdAt: Date; updatedAt: Date }): void { + const payload = { + threadId, + run: { + ...run, + threadId, + createdAt: run.createdAt.toISOString(), + updatedAt: run.updatedAt.toISOString(), + }, + }; + void this.publishToRooms([`thread:${threadId}`, `run:${run.id}`], 'run_status_changed', payload); + } + + private emitRunEvent(runId: string, threadId: string, payload: RunEventBroadcast): void { + const eventName = payload.mutation === 'update' ? 'run_event_updated' : 'run_event_appended'; + void this.publishToRooms([`run:${runId}`, `thread:${threadId}`], eventName, payload); + } + + private emitToolOutputChunk(payload: { + runId: string; + threadId: string; + eventId: string; + seqGlobal: number; + seqStream: number; + source: 'stdout' | 'stderr'; + ts: Date; + data: string; + }): void { + const eventPayload: ToolOutputChunkEvent = { + runId: payload.runId, + threadId: payload.threadId, + eventId: payload.eventId, + seqGlobal: payload.seqGlobal, + seqStream: payload.seqStream, + source: payload.source, + ts: payload.ts.toISOString(), + data: payload.data, + }; + const parsed = ToolOutputChunkEventSchema.safeParse(eventPayload); + if (!parsed.success) { + this.logger.error( + `NotificationsPublisher payload validation failed for tool_output_chunk${this.formatContext({ issues: parsed.error.issues })}`, + ); + return; + } + void this.publishToRooms([`run:${payload.runId}`, `thread:${payload.threadId}`], 'tool_output_chunk', parsed.data); + } + + private emitToolOutputTerminal(payload: { + runId: string; + threadId: string; + eventId: string; + exitCode: number | null; + status: 'success' | 'error' | 'timeout' | 'idle_timeout' | 'cancelled' | 'truncated'; + bytesStdout: number; + bytesStderr: number; + totalChunks: number; + droppedChunks: number; + savedPath?: string; + message?: string; + ts: Date; + }): void { + const eventPayload: ToolOutputTerminalEvent = { + runId: payload.runId, + threadId: payload.threadId, + eventId: payload.eventId, + exitCode: payload.exitCode, + status: payload.status, + bytesStdout: payload.bytesStdout, + bytesStderr: payload.bytesStderr, + totalChunks: payload.totalChunks, + droppedChunks: payload.droppedChunks, + savedPath: payload.savedPath ?? null, + message: payload.message ?? null, + ts: payload.ts.toISOString(), + }; + const parsed = ToolOutputTerminalEventSchema.safeParse(eventPayload); + if (!parsed.success) { + this.logger.error( + `NotificationsPublisher payload validation failed for tool_output_terminal${this.formatContext({ issues: parsed.error.issues })}`, + ); + return; + } + void this.publishToRooms([`run:${payload.runId}`, `thread:${payload.threadId}`], 'tool_output_terminal', parsed.data); + } + + scheduleThreadMetrics(threadId: string): void { + this.pendingThreads.add(threadId); + if (!this.metricsTimer) { + this.metricsTimer = setTimeout(this.flushMetricsQueue, this.COALESCE_MS); + } + } + + async scheduleThreadAndAncestorsMetrics(threadId: string): Promise { + try { + const prisma = this.prismaService.getClient(); + const rows: Array<{ id: string; parentId: string | null }> = await prisma.$queryRaw>` + with recursive rec as ( + select t.id, t."parentId" from "Thread" t where t.id = ${threadId}::uuid + union all + select p.id, p."parentId" from "Thread" p join rec r on r."parentId" = p.id + ) + select id, "parentId" from rec; + `; + for (const row of rows) this.scheduleThreadMetrics(row.id); + } catch (error) { + this.logger.error( + `NotificationsPublisher scheduleThreadAndAncestorsMetrics error${this.formatContext({ error: this.toSafeError(error) })}`, + ); + this.scheduleThreadMetrics(threadId); + } + } + + private readonly flushMetricsQueue = async (): Promise => { + const ids = Array.from(new Set(this.pendingThreads)); + this.pendingThreads.clear(); + this.metricsTimer = null; + if (!ids.length) return; + try { + const map = await this.metrics.getThreadsMetrics(ids); + for (const id of ids) { + const metrics = map[id]; + if (!metrics) continue; + void this.publishToRooms(['threads', `thread:${id}`], 'thread_activity_changed', { + threadId: id, + activity: metrics.activity, + }); + void this.publishToRooms(['threads', `thread:${id}`], 'thread_reminders_count', { + threadId: id, + remindersCount: metrics.remindersCount, + }); + } + } catch (error) { + this.logger.error( + `NotificationsPublisher flushMetricsQueue error${this.formatContext({ error: this.toSafeError(error) })}`, + ); + } + }; + + private toDate(value: string): Date | null { + const ts = new Date(value); + return Number.isNaN(ts.getTime()) ? null : ts; + } + + private async publishToRooms(rooms: NotificationRoom[], event: string, payload: unknown): Promise { + if (!rooms.length) return; + const envelope: NotificationEnvelope = { + id: randomUUID(), + ts: new Date().toISOString(), + source: 'platform-server', + rooms, + event, + payload, + }; + try { + await this.broker.publish(envelope); + } catch (error) { + this.logger.error( + `NotificationsPublisher failed to publish${this.formatContext({ event, rooms, error: this.toSafeError(error) })}`, + ); + } + } + + private formatContext(context: Record): string { + return ` ${JSON.stringify(context)}`; + } + + private toSafeError(error: unknown): { name?: string; message: string } { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + try { + return { message: JSON.stringify(error) }; + } catch { + return { message: String(error) }; + } + } +} diff --git a/packages/platform-server/src/notifications/notifications.schemas.ts b/packages/platform-server/src/notifications/notifications.schemas.ts new file mode 100644 index 000000000..300ba10d2 --- /dev/null +++ b/packages/platform-server/src/notifications/notifications.schemas.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +export const NodeStatusEventSchema = z + .object({ + nodeId: z.string(), + provisionStatus: z + .object({ + state: z.enum([ + 'not_ready', + 'provisioning', + 'ready', + 'deprovisioning', + 'provisioning_error', + 'deprovisioning_error', + ]), + details: z.unknown().optional(), + }) + .partial(), + updatedAt: z.string().datetime().optional(), + }) + .strict(); +export type NodeStatusEvent = z.infer; + +export const NodeStateEventSchema = z + .object({ + nodeId: z.string(), + state: z.record(z.string(), z.unknown()), + updatedAt: z.string().datetime(), + }) + .strict(); +export type NodeStateEvent = z.infer; + +export const ReminderCountSocketEventSchema = z + .object({ + nodeId: z.string(), + count: z.number().int().min(0), + updatedAt: z.string().datetime(), + }) + .strict(); +export type ReminderCountSocketEvent = z.infer; + +export const ToolOutputChunkEventSchema = z + .object({ + runId: z.string().uuid(), + threadId: z.string().uuid(), + eventId: z.string().uuid(), + seqGlobal: z.number().int().positive(), + seqStream: z.number().int().positive(), + source: z.enum(['stdout', 'stderr']), + ts: z.string().datetime(), + data: z.string(), + }) + .strict(); +export type ToolOutputChunkEvent = z.infer; + +export const ToolOutputTerminalEventSchema = z + .object({ + runId: z.string().uuid(), + threadId: z.string().uuid(), + eventId: z.string().uuid(), + exitCode: z.number().int().nullable(), + status: z.enum(['success', 'error', 'timeout', 'idle_timeout', 'cancelled', 'truncated']), + bytesStdout: z.number().int().min(0), + bytesStderr: z.number().int().min(0), + totalChunks: z.number().int().min(0), + droppedChunks: z.number().int().min(0), + savedPath: z.string().optional().nullable(), + message: z.string().optional().nullable(), + ts: z.string().datetime(), + }) + .strict(); +export type ToolOutputTerminalEvent = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 116f519cf..ccaa99c89 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -19,3 +19,5 @@ export type { ResolutionEventSource, ResolutionErrorCode, } from './references'; + +export * from './notifications'; diff --git a/packages/shared/src/notifications/index.ts b/packages/shared/src/notifications/index.ts new file mode 100644 index 000000000..07b6cba32 --- /dev/null +++ b/packages/shared/src/notifications/index.ts @@ -0,0 +1,28 @@ +export const NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; + +export type NotificationSource = 'platform-server'; + +export type StaticNotificationRoom = 'graph' | 'threads'; +export type ThreadNotificationRoom = `thread:${string}`; +export type RunNotificationRoom = `run:${string}`; +export type NodeNotificationRoom = `node:${string}`; +export type NotificationRoom = + | StaticNotificationRoom + | ThreadNotificationRoom + | RunNotificationRoom + | NodeNotificationRoom; + +export type NotificationEnvelope = { + id: string; + ts: string; + source: NotificationSource; + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; + +export type NotificationPublishRequest = { + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a66bbe527..8f2d51aa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,34 @@ importers: specifier: ^4.1.12 version: 4.1.12 + packages/notifications-gateway: + dependencies: + '@agyn/shared': + specifier: workspace:* + version: link:../shared + ioredis: + specifier: ^5.4.1 + version: 5.9.3 + pino: + specifier: ^10.1.0 + version: 10.1.0 + socket.io: + specifier: ^4.8.1 + version: 4.8.1 + zod: + specifier: ^4.1.9 + version: 4.1.12 + devDependencies: + '@types/node': + specifier: ^24.5.1 + version: 24.5.2 + tsx: + specifier: ^4.20.5 + version: 4.20.5 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + packages/platform-server: dependencies: '@agyn/docker-runner': @@ -220,6 +248,9 @@ importers: iconv-lite: specifier: ^0.6.3 version: 0.6.3 + ioredis: + specifier: ^5.4.1 + version: 5.9.3 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -256,9 +287,6 @@ importers: semver: specifier: ^7.6.3 version: 7.7.2 - socket.io: - specifier: ^4.8.1 - version: 4.8.1 tar-stream: specifier: ^3.1.7 version: 3.1.7 @@ -320,9 +348,6 @@ importers: prisma: specifier: ^6.18.0 version: 6.18.0(typescript@5.8.3) - socket.io-client: - specifier: ^4.8.1 - version: 4.8.1 ts-node: specifier: ^10.0.0 version: 10.9.2(@swc/core@1.15.5)(@types/node@24.5.2)(typescript@5.8.3) @@ -1508,6 +1533,9 @@ packages: '@types/node': optional: true + '@ioredis/commands@1.5.0': + resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -4587,6 +4615,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -4896,6 +4928,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5757,6 +5793,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis@5.9.3: + resolution: {integrity: sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6360,9 +6400,15 @@ packages: lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.flattendeep@4.4.0: resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -7390,6 +7436,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -7693,6 +7747,9 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -9273,6 +9330,8 @@ snapshots: optionalDependencies: '@types/node': 24.5.2 + '@ioredis/commands@1.5.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -12281,6 +12340,15 @@ snapshots: msw: 2.11.3(@types/node@20.19.19)(typescript@5.9.2) vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) + '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + msw: 2.11.3(@types/node@24.5.2)(typescript@5.8.3) + vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) + '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -12782,6 +12850,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.1.1) @@ -13067,6 +13137,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -14088,6 +14160,20 @@ snapshots: internmap@2.0.3: {} + ioredis@5.9.3: + dependencies: + '@ioredis/commands': 1.5.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} ipaddr.js@2.2.0: {} @@ -14871,8 +14957,12 @@ snapshots: lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} + lodash.flattendeep@4.4.0: {} + lodash.isarguments@3.1.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -16186,6 +16276,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redux@4.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -16585,6 +16681,8 @@ snapshots: dependencies: type-fest: 0.7.1 + standard-as-callback@2.1.0: {} + state-local@1.0.7: {} statuses@2.0.1: {} @@ -17239,7 +17337,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From 260efa405834444077d7ec291022c272cea9f149 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 19:45:15 +0000 Subject: [PATCH 02/15] fix(notifications): harden gateway subscriber --- packages/notifications-gateway/package.json | 6 +- .../src/dispatch.test.ts | 61 ++++++++++++ .../notifications-gateway/src/dispatch.ts | 18 ++++ packages/notifications-gateway/src/errors.ts | 17 ++++ packages/notifications-gateway/src/index.ts | 24 +---- .../redis/notifications-subscriber.test.ts | 92 +++++++++++++++++++ .../src/redis/notifications-subscriber.ts | 27 +++--- .../notifications-gateway/vitest.config.ts | 11 +++ pnpm-lock.yaml | 3 + 9 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 packages/notifications-gateway/src/dispatch.test.ts create mode 100644 packages/notifications-gateway/src/dispatch.ts create mode 100644 packages/notifications-gateway/src/errors.ts create mode 100644 packages/notifications-gateway/src/redis/notifications-subscriber.test.ts create mode 100644 packages/notifications-gateway/vitest.config.ts diff --git a/packages/notifications-gateway/package.json b/packages/notifications-gateway/package.json index 98afdc30b..b9c6647fc 100644 --- a/packages/notifications-gateway/package.json +++ b/packages/notifications-gateway/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "tsx watch --clear-screen=false src/index.ts", "build": "tsc -p tsconfig.json", - "start": "node dist/index.js" + "start": "node dist/index.js", + "test": "vitest run" }, "dependencies": { "@agyn/shared": "workspace:*", @@ -19,6 +20,7 @@ "devDependencies": { "@types/node": "^24.5.1", "tsx": "^4.20.5", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "3.2.4" } } diff --git a/packages/notifications-gateway/src/dispatch.test.ts b/packages/notifications-gateway/src/dispatch.test.ts new file mode 100644 index 000000000..ab203b23f --- /dev/null +++ b/packages/notifications-gateway/src/dispatch.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { NotificationEnvelope } from '@agyn/shared'; +import type { Logger } from './logger'; +import type { Server as SocketIOServer } from 'socket.io'; +import { dispatchToRooms } from './dispatch'; + +const createLogger = (): Logger => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }) as unknown as Logger; + +const createEnvelope = (): NotificationEnvelope => ({ + id: 'evt-1', + ts: new Date('2024-01-01T00:00:00.000Z').toISOString(), + source: 'platform-server', + rooms: ['graph', 'thread:abc123'], + event: 'thread_updated', + payload: { threadId: 'thread-1' }, +}); + +describe('dispatchToRooms', () => { + it('emits payload to every requested room', () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as unknown as SocketIOServer; + const logger = createLogger(); + + const envelope = createEnvelope(); + dispatchToRooms(io, envelope, logger); + + expect(to).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenNthCalledWith(1, envelope.event, envelope.payload); + expect(emit).toHaveBeenNthCalledWith(2, envelope.event, envelope.payload); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('logs and continues when emit fails for a room', () => { + const emit = vi + .fn() + .mockImplementationOnce(() => { + throw new Error('boom'); + }) + .mockImplementation(() => undefined); + const to = vi.fn(() => ({ emit })); + const io = { to } as unknown as SocketIOServer; + const logger = createLogger(); + const envelope = createEnvelope(); + + dispatchToRooms(io, envelope, logger); + + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ room: 'graph', event: envelope.event }), + 'emit failed', + ); + expect(emit).toHaveBeenCalledTimes(envelope.rooms.length); + }); +}); diff --git a/packages/notifications-gateway/src/dispatch.ts b/packages/notifications-gateway/src/dispatch.ts new file mode 100644 index 000000000..0e9a1fa2c --- /dev/null +++ b/packages/notifications-gateway/src/dispatch.ts @@ -0,0 +1,18 @@ +import type { NotificationEnvelope } from '@agyn/shared'; +import type { Server as SocketIOServer } from 'socket.io'; +import type { Logger } from './logger'; +import { serializeError } from './errors'; + +export const dispatchToRooms = ( + io: SocketIOServer, + envelope: NotificationEnvelope, + logger: Logger, +): void => { + for (const room of envelope.rooms) { + try { + io.to(room).emit(envelope.event, envelope.payload); + } catch (error) { + logger.warn({ room, event: envelope.event, error: serializeError(error) }, 'emit failed'); + } + } +}; diff --git a/packages/notifications-gateway/src/errors.ts b/packages/notifications-gateway/src/errors.ts new file mode 100644 index 000000000..ec9ce86a1 --- /dev/null +++ b/packages/notifications-gateway/src/errors.ts @@ -0,0 +1,17 @@ +export type SerializedError = { name?: string; message: string }; + +export const serializeError = (error: unknown): SerializedError => { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + + if (error && typeof error === 'object') { + try { + return { message: JSON.stringify(error) }; + } catch { + return { message: '[object]' }; + } + } + + return { message: String(error) }; +}; diff --git a/packages/notifications-gateway/src/index.ts b/packages/notifications-gateway/src/index.ts index 4eac56e74..0e1a78a16 100644 --- a/packages/notifications-gateway/src/index.ts +++ b/packages/notifications-gateway/src/index.ts @@ -4,6 +4,8 @@ import { loadConfig } from './config'; import { createLogger } from './logger'; import { createSocketServer } from './socket/server'; import { NotificationsSubscriber } from './redis/notifications-subscriber'; +import { dispatchToRooms } from './dispatch'; +import { serializeError } from './errors'; import type { NotificationEnvelope } from '@agyn/shared'; import type { Logger } from './logger'; import type { Server as SocketIOServer } from 'socket.io'; @@ -46,28 +48,6 @@ async function main(): Promise { process.once('SIGINT', shutdown); } -const dispatchToRooms = (io: SocketIOServer, envelope: NotificationEnvelope, logger: Logger) => { - for (const room of envelope.rooms) { - try { - io.to(room).emit(envelope.event, envelope.payload); - } catch (error) { - logger.warn({ room, event: envelope.event, error: serializeError(error) }, 'emit failed'); - } - } -}; - -const serializeError = (error: unknown): { name?: string; message: string } => { - if (error instanceof Error) return { name: error.name, message: error.message }; - if (typeof error === 'object') { - try { - return { message: JSON.stringify(error) }; - } catch { - return { message: '[object]' }; - } - } - return { message: String(error) }; -}; - void main().catch((error) => { const serialized = serializeError(error); // eslint-disable-next-line no-console -- fallback for bootstrap errors diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts new file mode 100644 index 000000000..5cf1b1f1b --- /dev/null +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts @@ -0,0 +1,92 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationsSubscriber } from './notifications-subscriber'; +import type { Logger } from '../logger'; + +class RedisStub extends EventEmitter { + connect = vi.fn(async () => {}); + subscribe = vi.fn(async () => 1); + quit = vi.fn(async () => {}); +} + +type RedisCtorArgs = [string, Record?]; + +const redisFactory = vi.fn(); + +vi.mock('ioredis', () => ({ + default: vi.fn((...args: RedisCtorArgs) => redisFactory(...args)), +})); + +const createLogger = (): Logger => + ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), + }) as unknown as Logger; + +describe('NotificationsSubscriber', () => { + const options = { url: 'redis://localhost:6379/0', channel: 'notifications.v1' } as const; + let redis: RedisStub; + + beforeEach(() => { + vi.clearAllMocks(); + redis = new RedisStub(); + redisFactory.mockImplementation(() => redis); + }); + + it('emits parsed notifications when payload is valid', async () => { + const logger = createLogger(); + const subscriber = new NotificationsSubscriber(options, logger); + const notificationSpy = vi.fn(); + subscriber.on('notification', notificationSpy); + + await subscriber.start(); + const envelope = { + id: 'evt-1', + ts: new Date('2024-01-01T00:00:00.000Z').toISOString(), + source: 'platform-server' as const, + rooms: ['graph'], + event: 'thread_updated', + payload: { foo: 'bar' }, + }; + + redis.emit('message', options.channel, JSON.stringify(envelope)); + + expect(notificationSpy).toHaveBeenCalledWith(envelope); + await subscriber.stop(); + expect(redis.quit).toHaveBeenCalledTimes(1); + }); + + it('logs and skips invalid payloads', async () => { + const logger = createLogger(); + const subscriber = new NotificationsSubscriber(options, logger); + const notificationSpy = vi.fn(); + subscriber.on('notification', notificationSpy); + + await subscriber.start(); + redis.emit('message', options.channel, '{not-json'); + + expect(notificationSpy).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ raw: '{not-json' }), + 'failed to parse notification', + ); + }); + + it('surfaces subscription failures via error events without crashing', async () => { + const logger = createLogger(); + const subscriber = new NotificationsSubscriber(options, logger); + const errorSpy = vi.fn(); + subscriber.on('error', errorSpy); + const failure = new Error('subscribe failed'); + redis.subscribe.mockRejectedValueOnce(failure); + + await expect(subscriber.start()).rejects.toThrow(failure); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ channel: options.channel, error: { name: 'Error', message: failure.message } }), + 'failed to subscribe to notifications channel', + ); + expect(errorSpy).toHaveBeenCalledWith(failure); + }); +}); diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.ts index 7fcc235cc..3f4d8d232 100644 --- a/packages/notifications-gateway/src/redis/notifications-subscriber.ts +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.ts @@ -3,6 +3,7 @@ import Redis from 'ioredis'; import type { Logger } from '../logger'; import type { NotificationEnvelope } from '@agyn/shared'; import { NotificationEnvelopeSchema } from './schema'; +import { serializeError } from '../errors'; export class NotificationsSubscriber extends EventEmitter { private redis: Redis | null = null; @@ -32,10 +33,18 @@ export class NotificationsSubscriber extends EventEmitter { this.emit('ready'); }); await this.redis.connect(); - await this.redis.subscribe(this.options.channel, (err) => { - if (err) throw err; + try { + await this.redis.subscribe(this.options.channel); this.logger.info({ channel: this.options.channel }, 'subscribed to notifications channel'); - }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + { channel: this.options.channel, error: serializeError(err) }, + 'failed to subscribe to notifications channel', + ); + this.emit('error', err); + throw err; + } this.redis.on('message', (channel, message) => { if (channel !== this.options.channel) return; this.handleMessage(message); @@ -60,15 +69,3 @@ export class NotificationsSubscriber extends EventEmitter { } } } - -const serializeError = (error: unknown): { name?: string; message: string } => { - if (error instanceof Error) return { name: error.name, message: error.message }; - if (typeof error === 'object') { - try { - return { message: JSON.stringify(error) }; - } catch { - return { message: '[object]' }; - } - } - return { message: String(error) }; -}; diff --git a/packages/notifications-gateway/vitest.config.ts b/packages/notifications-gateway/vitest.config.ts new file mode 100644 index 000000000..752350702 --- /dev/null +++ b/packages/notifications-gateway/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + reporter: ['text', 'lcov'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f2d51aa3..b327432af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.5.2)(jiti@2.5.1)(jsdom@27.1.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(tsx@4.20.5)(yaml@2.8.1) packages/platform-server: dependencies: From 9b3bafd3debe9bcb42f8035929b1d2a28f90b8ff Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 20:00:00 +0000 Subject: [PATCH 03/15] fix(notifications): localize shared types --- .../src/notifications/notifications.broker.ts | 2 +- .../notifications/notifications.publisher.ts | 2 +- .../src/shared/types/notifications.ts | 28 ++++++++++++++++++ packages/shared/src/notifications/index.ts | 29 +------------------ 4 files changed, 31 insertions(+), 30 deletions(-) create mode 100644 packages/platform-server/src/shared/types/notifications.ts diff --git a/packages/platform-server/src/notifications/notifications.broker.ts b/packages/platform-server/src/notifications/notifications.broker.ts index 86fd4b028..d6649c859 100644 --- a/packages/platform-server/src/notifications/notifications.broker.ts +++ b/packages/platform-server/src/notifications/notifications.broker.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import type { NotificationEnvelope } from '@agyn/shared'; +import type { NotificationEnvelope } from '../shared/types/notifications'; import { ConfigService } from '../core/services/config.service'; @Injectable() diff --git a/packages/platform-server/src/notifications/notifications.publisher.ts b/packages/platform-server/src/notifications/notifications.publisher.ts index 94fbb528a..b260a2a5d 100644 --- a/packages/platform-server/src/notifications/notifications.publisher.ts +++ b/packages/platform-server/src/notifications/notifications.publisher.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import type { NotificationEnvelope, NotificationRoom } from '@agyn/shared'; +import type { NotificationEnvelope, NotificationRoom } from '../shared/types/notifications'; import { z } from 'zod'; import { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; import { ThreadsMetricsService } from '../agents/threads.metrics.service'; diff --git a/packages/platform-server/src/shared/types/notifications.ts b/packages/platform-server/src/shared/types/notifications.ts new file mode 100644 index 000000000..07b6cba32 --- /dev/null +++ b/packages/platform-server/src/shared/types/notifications.ts @@ -0,0 +1,28 @@ +export const NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; + +export type NotificationSource = 'platform-server'; + +export type StaticNotificationRoom = 'graph' | 'threads'; +export type ThreadNotificationRoom = `thread:${string}`; +export type RunNotificationRoom = `run:${string}`; +export type NodeNotificationRoom = `node:${string}`; +export type NotificationRoom = + | StaticNotificationRoom + | ThreadNotificationRoom + | RunNotificationRoom + | NodeNotificationRoom; + +export type NotificationEnvelope = { + id: string; + ts: string; + source: NotificationSource; + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; + +export type NotificationPublishRequest = { + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; diff --git a/packages/shared/src/notifications/index.ts b/packages/shared/src/notifications/index.ts index 07b6cba32..df8f0f010 100644 --- a/packages/shared/src/notifications/index.ts +++ b/packages/shared/src/notifications/index.ts @@ -1,28 +1 @@ -export const NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; - -export type NotificationSource = 'platform-server'; - -export type StaticNotificationRoom = 'graph' | 'threads'; -export type ThreadNotificationRoom = `thread:${string}`; -export type RunNotificationRoom = `run:${string}`; -export type NodeNotificationRoom = `node:${string}`; -export type NotificationRoom = - | StaticNotificationRoom - | ThreadNotificationRoom - | RunNotificationRoom - | NodeNotificationRoom; - -export type NotificationEnvelope = { - id: string; - ts: string; - source: NotificationSource; - rooms: NotificationRoom[]; - event: EventName; - payload: Payload; -}; - -export type NotificationPublishRequest = { - rooms: NotificationRoom[]; - event: EventName; - payload: Payload; -}; +export * from '../../../platform-server/src/shared/types/notifications'; From b30b72cf26003d770c0a5d4c5ed33faa0b21a48b Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 20:27:02 +0000 Subject: [PATCH 04/15] fix(notifications): stabilize gateway build --- .../redis/notifications-subscriber.test.ts | 3 +- .../notifications-gateway/src/redis/schema.ts | 4 +- packages/notifications-gateway/src/rooms.ts | 7 +- packages/notifications-gateway/tsconfig.json | 6 +- .../__tests__/api.guard.mcp.command.test.ts | 2 +- .../__tests__/graph.mcp.integration.test.ts | 2 +- ...ime.simpleAgent.config.propagation.test.ts | 2 +- .../mcp.enabledTools.boot.integration.test.ts | 2 +- .../memory.runtime.integration.test.ts | 2 +- .../__tests__/routes.variables.test.ts | 2 +- .../runtime.config.unknownKeys.test.ts | 2 +- .../__tests__/templateRegistry.schema.test.ts | 2 +- .../templateRegistry.toSchema.ports.test.ts | 2 +- .../src/agents/agents.persistence.service.ts | 2 +- .../src/graph-core/liveGraph.manager.ts | 2 +- .../src/graph-core/templateRegistry.ts | 2 +- .../src/graph/controllers/graph.controller.ts | 2 +- .../controllers/graphPersist.controller.ts | 2 +- .../src/graph/fsGraph.repository.ts | 2 +- .../platform-server/src/graph/graph.guard.ts | 2 +- .../src/graph/graph.repository.ts | 2 +- .../src/graph/graphSchema.validator.ts | 2 +- .../src/graph/liveGraph.types.ts | 2 +- .../src/graph/ports.registry.ts | 2 +- .../platform-server/src/graph/ports.types.ts | 2 +- .../graph/services/graphVariables.service.ts | 2 +- packages/platform-server/src/graph/types.ts | 2 +- .../src/notifications/notifications.broker.ts | 2 +- .../notifications/notifications.publisher.ts | 2 +- .../src/shared/shared.module.ts | 3 - .../src/shared/types/graph.types.ts | 139 ------------------ .../src/shared/types/notifications.ts | 28 ---- .../platform-server/src/utils/references.ts | 67 +++------ packages/platform-server/tsconfig.json | 6 +- packages/shared/package.json | 17 ++- .../common.types.ts => shared/src/common.ts} | 2 - packages/shared/src/graph.ts | 129 ++++++++++++++++ packages/shared/src/index.ts | 25 +--- packages/shared/src/notifications/index.ts | 29 +++- packages/shared/src/references.ts | 59 ++++++-- packages/shared/tsconfig.json | 17 +++ pnpm-lock.yaml | 6 +- 42 files changed, 301 insertions(+), 296 deletions(-) delete mode 100644 packages/platform-server/src/shared/types/graph.types.ts delete mode 100644 packages/platform-server/src/shared/types/notifications.ts rename packages/{platform-server/src/shared/types/common.types.ts => shared/src/common.ts} (66%) create mode 100644 packages/shared/src/graph.ts create mode 100644 packages/shared/tsconfig.json diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts index 5cf1b1f1b..11e2f19be 100644 --- a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts @@ -11,7 +11,8 @@ class RedisStub extends EventEmitter { type RedisCtorArgs = [string, Record?]; -const redisFactory = vi.fn(); +type RedisFactory = (...args: RedisCtorArgs) => RedisStub; +const redisFactory = vi.fn(); vi.mock('ioredis', () => ({ default: vi.fn((...args: RedisCtorArgs) => redisFactory(...args)), diff --git a/packages/notifications-gateway/src/redis/schema.ts b/packages/notifications-gateway/src/redis/schema.ts index 566cf8a48..205042da7 100644 --- a/packages/notifications-gateway/src/redis/schema.ts +++ b/packages/notifications-gateway/src/redis/schema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import type { NotificationEnvelope } from '@agyn/shared'; import { RoomSchema } from '../rooms'; -export const NotificationEnvelopeSchema = z +export const NotificationEnvelopeSchema: z.ZodType = z .object({ id: z.string().min(1), ts: z.string().datetime(), @@ -11,4 +11,4 @@ export const NotificationEnvelopeSchema = z event: z.string().min(1), payload: z.unknown(), }) - .strict() satisfies z.ZodType; + .strict(); diff --git a/packages/notifications-gateway/src/rooms.ts b/packages/notifications-gateway/src/rooms.ts index 2a5735c95..ef7b63195 100644 --- a/packages/notifications-gateway/src/rooms.ts +++ b/packages/notifications-gateway/src/rooms.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; +import type { NotificationRoom } from '@agyn/shared'; -export const RoomSchema = z.union([ +const BaseRoomSchema = z.union([ z.literal('graph'), z.literal('threads'), z.string().regex(/^thread:[0-9a-z-]{1,64}$/i), @@ -8,4 +9,8 @@ export const RoomSchema = z.union([ z.string().regex(/^node:[0-9a-z-]{1,64}$/i), ]); +export const RoomSchema: z.ZodType = BaseRoomSchema.transform( + (value) => value as NotificationRoom, +); + export type ValidRoom = z.infer; diff --git a/packages/notifications-gateway/tsconfig.json b/packages/notifications-gateway/tsconfig.json index 1c622efd7..00f306cec 100644 --- a/packages/notifications-gateway/tsconfig.json +++ b/packages/notifications-gateway/tsconfig.json @@ -10,11 +10,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "types": ["node"], - "baseUrl": ".", - "paths": { - "@agyn/shared": ["../shared/src/index.ts"], - "@agyn/shared/*": ["../shared/src/*"] - } + "baseUrl": "." }, "include": ["src"], "exclude": ["dist", "node_modules"] diff --git a/packages/platform-server/__tests__/api.guard.mcp.command.test.ts b/packages/platform-server/__tests__/api.guard.mcp.command.test.ts index 8a8a8f2e8..01f32f66a 100644 --- a/packages/platform-server/__tests__/api.guard.mcp.command.test.ts +++ b/packages/platform-server/__tests__/api.guard.mcp.command.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { enforceMcpCommandMutationGuard } from '../src/graph/graph.guard'; import { GraphErrorCode } from '../src/graph/errors'; -import type { PersistedGraph, PersistedGraphUpsertRequest } from '../src/shared/types/graph.types'; +import type { PersistedGraph, PersistedGraphUpsertRequest } from '@agyn/shared'; import type { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; describe('API guard: MCP command mutation forbidden', () => { diff --git a/packages/platform-server/__tests__/graph.mcp.integration.test.ts b/packages/platform-server/__tests__/graph.mcp.integration.test.ts index 65d8be12c..0d47f1ccb 100644 --- a/packages/platform-server/__tests__/graph.mcp.integration.test.ts +++ b/packages/platform-server/__tests__/graph.mcp.integration.test.ts @@ -13,7 +13,7 @@ import { ModuleRef } from '@nestjs/core'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { GraphRepository } from '../src/graph/graph.repository'; -import type { GraphDefinition } from '../src/shared/types/graph.types'; +import type { GraphDefinition } from '@agyn/shared'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { RunSignalsRegistry } from '../src/agents/run-signals.service'; import { ReferenceResolverService } from '../src/utils/reference-resolver.service'; diff --git a/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts b/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts index 2cc5e56cb..874895429 100644 --- a/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts +++ b/packages/platform-server/__tests__/live.graph.runtime.simpleAgent.config.propagation.test.ts @@ -3,7 +3,7 @@ import { Test } from '@nestjs/testing'; import { ModuleRef } from '@nestjs/core'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { GraphRepository } from '../src/graph/graph.repository'; -import type { GraphDefinition } from '../src/shared/types/graph.types'; +import type { GraphDefinition } from '@agyn/shared'; import { buildTemplateRegistry } from '../src/templates'; import { ContainerService } from '@agyn/docker-runner'; import { ContainerRegistry } from '../src/infra/container/container.registry'; diff --git a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts b/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts index c22de7440..97b50c794 100644 --- a/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts +++ b/packages/platform-server/__tests__/mcp.enabledTools.boot.integration.test.ts @@ -13,7 +13,7 @@ import { ModuleRef } from '@nestjs/core'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { GraphRepository } from '../src/graph/graph.repository'; -import type { GraphDefinition, PersistedGraph } from '../src/shared/types/graph.types'; +import type { GraphDefinition, PersistedGraph } from '@agyn/shared'; import { AgentsPersistenceService } from '../src/agents/agents.persistence.service'; import { RunSignalsRegistry } from '../src/agents/run-signals.service'; import { EventsBusService } from '../src/events/events-bus.service'; diff --git a/packages/platform-server/__tests__/memory.runtime.integration.test.ts b/packages/platform-server/__tests__/memory.runtime.integration.test.ts index d8e7e343e..a7258f1ab 100644 --- a/packages/platform-server/__tests__/memory.runtime.integration.test.ts +++ b/packages/platform-server/__tests__/memory.runtime.integration.test.ts @@ -5,7 +5,7 @@ import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import type { LLMContext } from '../src/llm/types'; import { Signal } from '../src/signal'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { GraphDefinition } from '../src/shared/types/graph.types'; +import type { GraphDefinition } from '@agyn/shared'; import Node from '../src/nodes/base/Node'; import { GraphRepository } from '../src/graph/graph.repository'; import { diff --git a/packages/platform-server/__tests__/routes.variables.test.ts b/packages/platform-server/__tests__/routes.variables.test.ts index b4da6a32c..d0aca4294 100644 --- a/packages/platform-server/__tests__/routes.variables.test.ts +++ b/packages/platform-server/__tests__/routes.variables.test.ts @@ -3,7 +3,7 @@ import Fastify from 'fastify'; import { GraphVariablesController } from '../src/graph/controllers/graphVariables.controller'; import type { GraphRepository } from '../src/graph/graph.repository'; import { GraphVariablesService } from '../src/graph/services/graphVariables.service'; -import type { PersistedGraph } from '../src/shared/types/graph.types'; +import type { PersistedGraph } from '@agyn/shared'; class InMemoryPrismaClient { variableLocal = { diff --git a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts index bf8eb0d91..ea572b245 100644 --- a/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts +++ b/packages/platform-server/__tests__/runtime.config.unknownKeys.test.ts @@ -4,7 +4,7 @@ import { GraphRepository } from '../src/graph/graph.repository.js'; import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; import type { TemplatePortConfig } from '../src/graph/ports.types'; -import type { GraphDefinition } from '../src/shared/types/graph.types'; +import type { GraphDefinition } from '@agyn/shared'; import { GraphError } from '../src/graph/types'; import { ModuleRef } from '@nestjs/core'; import Node from '../src/nodes/base/Node'; diff --git a/packages/platform-server/__tests__/templateRegistry.schema.test.ts b/packages/platform-server/__tests__/templateRegistry.schema.test.ts index ba6a7aba1..019b093b1 100644 --- a/packages/platform-server/__tests__/templateRegistry.schema.test.ts +++ b/packages/platform-server/__tests__/templateRegistry.schema.test.ts @@ -1,7 +1,7 @@ import { ModuleRef } from '@nestjs/core'; import { describe, expect, it } from 'vitest'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { TemplateKind, TemplateNodeSchema } from '../src/shared/types/graph.types'; +import type { TemplateKind, TemplateNodeSchema } from '@agyn/shared'; class DummyNode { getPortConfig() { return { sourcePorts: { out: { kind: 'instance' as const } }, targetPorts: { inp: { kind: 'instance' as const } } }; diff --git a/packages/platform-server/__tests__/templateRegistry.toSchema.ports.test.ts b/packages/platform-server/__tests__/templateRegistry.toSchema.ports.test.ts index 742d1c5d2..ed5c9c654 100644 --- a/packages/platform-server/__tests__/templateRegistry.toSchema.ports.test.ts +++ b/packages/platform-server/__tests__/templateRegistry.toSchema.ports.test.ts @@ -3,7 +3,7 @@ import { ModuleRef } from '@nestjs/core'; import { describe, expect, it } from 'vitest'; import type { TemplatePortConfig } from '../src/graph/ports.types'; import { TemplateRegistry } from '../src/graph-core/templateRegistry'; -import type { TemplateNodeSchema } from '../src/shared/types/graph.types'; +import type { TemplateNodeSchema } from '@agyn/shared'; import Node from '../src/nodes/base/Node'; // Define a minimal DummyNode class matching Node contract diff --git a/packages/platform-server/src/agents/agents.persistence.service.ts b/packages/platform-server/src/agents/agents.persistence.service.ts index 59713404f..ac2b9dae9 100644 --- a/packages/platform-server/src/agents/agents.persistence.service.ts +++ b/packages/platform-server/src/agents/agents.persistence.service.ts @@ -12,7 +12,7 @@ import type { ResponseInputItem } from 'openai/resources/responses/responses.mjs import { PrismaService } from '../core/services/prisma.service'; import { TemplateRegistry } from '../graph-core/templateRegistry'; import { GraphRepository } from '../graph/graph.repository'; -import type { PersistedGraphNode } from '../shared/types/graph.types'; +import type { PersistedGraphNode } from '@agyn/shared'; import { toPrismaJsonValue } from '../llm/services/messages.serialization'; import { coerceRole } from '../llm/services/messages.normalization'; import { ChannelDescriptorSchema, type ChannelDescriptor } from '../messaging/types'; diff --git a/packages/platform-server/src/graph-core/liveGraph.manager.ts b/packages/platform-server/src/graph-core/liveGraph.manager.ts index 26cc783a8..21c7f5fcf 100644 --- a/packages/platform-server/src/graph-core/liveGraph.manager.ts +++ b/packages/platform-server/src/graph-core/liveGraph.manager.ts @@ -6,7 +6,7 @@ import { LiveNode, edgeKey, } from '../graph/liveGraph.types'; -import type { EdgeDef, GraphDefinition, NodeDef } from '../shared/types/graph.types'; +import type { EdgeDef, GraphDefinition, NodeDef } from '@agyn/shared'; import { GraphError } from '../graph/types'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; diff --git a/packages/platform-server/src/graph-core/templateRegistry.ts b/packages/platform-server/src/graph-core/templateRegistry.ts index 39aa38ccb..81b2eacd9 100644 --- a/packages/platform-server/src/graph-core/templateRegistry.ts +++ b/packages/platform-server/src/graph-core/templateRegistry.ts @@ -3,7 +3,7 @@ import { ModuleRef } from '@nestjs/core'; import type { Constructor } from 'type-fest'; import type { TemplatePortConfig } from '../graph/ports.types'; -import type { TemplateKind, TemplateNodeSchema } from '../shared/types/graph.types'; +import type { TemplateKind, TemplateNodeSchema } from '@agyn/shared'; import Node from '../nodes/base/Node'; export interface TemplateMeta { diff --git a/packages/platform-server/src/graph/controllers/graph.controller.ts b/packages/platform-server/src/graph/controllers/graph.controller.ts index 3f4be3896..b9804a658 100644 --- a/packages/platform-server/src/graph/controllers/graph.controller.ts +++ b/packages/platform-server/src/graph/controllers/graph.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Post, Put, Param, Body, HttpCode, HttpException, HttpStatus, Inject } from '@nestjs/common'; import { z } from 'zod'; import { TemplateRegistry } from '../../graph-core/templateRegistry'; -import type { TemplateNodeSchema } from '../../shared/types/graph.types'; +import type { TemplateNodeSchema } from '@agyn/shared'; import { LiveGraphRuntime } from '../../graph-core/liveGraph.manager'; import type { NodeStatusState } from '../../nodes/base/Node'; import { NodeStateService } from '../nodeState.service'; diff --git a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts b/packages/platform-server/src/graph/controllers/graphPersist.controller.ts index 4b0bddbc1..20e1fe803 100644 --- a/packages/platform-server/src/graph/controllers/graphPersist.controller.ts +++ b/packages/platform-server/src/graph/controllers/graphPersist.controller.ts @@ -8,7 +8,7 @@ import type { GraphDefinition, PersistedGraphUpsertRequest, PersistedGraphUpsertResponse, -} from '../../shared/types/graph.types'; +} from '@agyn/shared'; import { z } from 'zod'; import { GraphErrorCode } from '../errors'; import { GraphGuard } from '../graph.guard'; diff --git a/packages/platform-server/src/graph/fsGraph.repository.ts b/packages/platform-server/src/graph/fsGraph.repository.ts index caf0fbe58..7e03e1b7d 100644 --- a/packages/platform-server/src/graph/fsGraph.repository.ts +++ b/packages/platform-server/src/graph/fsGraph.repository.ts @@ -7,7 +7,7 @@ import type { PersistedGraphNode, PersistedGraphUpsertRequest, PersistedGraphUpsertResponse, -} from '../shared/types/graph.types'; +} from '@agyn/shared'; import { validatePersistedGraph } from './graphSchema.validator'; import { GraphRepository } from './graph.repository'; import type { GraphAuthor } from './graph.repository'; diff --git a/packages/platform-server/src/graph/graph.guard.ts b/packages/platform-server/src/graph/graph.guard.ts index 6a4414484..3e45667a3 100644 --- a/packages/platform-server/src/graph/graph.guard.ts +++ b/packages/platform-server/src/graph/graph.guard.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { PersistedGraph, PersistedGraphUpsertRequest } from '../shared/types/graph.types'; +import type { PersistedGraph, PersistedGraphUpsertRequest } from '@agyn/shared'; import { GraphErrorCode } from './errors'; import type { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; import type { NodeStatusState } from '../nodes/base/Node'; diff --git a/packages/platform-server/src/graph/graph.repository.ts b/packages/platform-server/src/graph/graph.repository.ts index 63a7103a2..0ead81b56 100644 --- a/packages/platform-server/src/graph/graph.repository.ts +++ b/packages/platform-server/src/graph/graph.repository.ts @@ -2,7 +2,7 @@ import type { PersistedGraph, PersistedGraphUpsertRequest, PersistedGraphUpsertResponse, -} from '../shared/types/graph.types'; +} from '@agyn/shared'; export type GraphAuthor = { name?: string; email?: string }; diff --git a/packages/platform-server/src/graph/graphSchema.validator.ts b/packages/platform-server/src/graph/graphSchema.validator.ts index 4d4f88b90..f419a7b98 100644 --- a/packages/platform-server/src/graph/graphSchema.validator.ts +++ b/packages/platform-server/src/graph/graphSchema.validator.ts @@ -1,4 +1,4 @@ -import type { PersistedGraphUpsertRequest, TemplateNodeSchema } from '../shared/types/graph.types'; +import type { PersistedGraphUpsertRequest, TemplateNodeSchema } from '@agyn/shared'; export function validatePersistedGraph(req: PersistedGraphUpsertRequest, schema: TemplateNodeSchema[]): void { const templateSet = new Set(schema.map((s) => s.name)); diff --git a/packages/platform-server/src/graph/liveGraph.types.ts b/packages/platform-server/src/graph/liveGraph.types.ts index 0562cc232..3debf0c89 100644 --- a/packages/platform-server/src/graph/liveGraph.types.ts +++ b/packages/platform-server/src/graph/liveGraph.types.ts @@ -1,4 +1,4 @@ -import type { EdgeDef, GraphDefinition, NodeDef } from '../shared/types/graph.types'; +import type { EdgeDef, GraphDefinition, NodeDef } from '@agyn/shared'; import { GraphError } from './types'; import { Node } from '../nodes/base/Node'; diff --git a/packages/platform-server/src/graph/ports.registry.ts b/packages/platform-server/src/graph/ports.registry.ts index f25546a40..215eadcc9 100644 --- a/packages/platform-server/src/graph/ports.registry.ts +++ b/packages/platform-server/src/graph/ports.registry.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { EdgeDef } from '../shared/types/graph.types'; +import type { EdgeDef } from '@agyn/shared'; import { TemplatePortsRegistry, ResolvedEdgePorts, diff --git a/packages/platform-server/src/graph/ports.types.ts b/packages/platform-server/src/graph/ports.types.ts index c1a0bf824..641322f53 100644 --- a/packages/platform-server/src/graph/ports.types.ts +++ b/packages/platform-server/src/graph/ports.types.ts @@ -1,4 +1,4 @@ -import type { EdgeDef } from '../shared/types/graph.types'; +import type { EdgeDef } from '@agyn/shared'; export type PortKind = 'instance' | 'method'; diff --git a/packages/platform-server/src/graph/services/graphVariables.service.ts b/packages/platform-server/src/graph/services/graphVariables.service.ts index 78a4702cf..bbbb1ac9b 100644 --- a/packages/platform-server/src/graph/services/graphVariables.service.ts +++ b/packages/platform-server/src/graph/services/graphVariables.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { PrismaService } from '../../core/services/prisma.service'; import { GraphRepository } from '../graph.repository'; -import type { PersistedGraph } from '../../shared/types/graph.types'; +import type { PersistedGraph } from '@agyn/shared'; export type VarItem = { key: string; graph: string | null; local: string | null }; diff --git a/packages/platform-server/src/graph/types.ts b/packages/platform-server/src/graph/types.ts index cb3910cd9..650c18154 100644 --- a/packages/platform-server/src/graph/types.ts +++ b/packages/platform-server/src/graph/types.ts @@ -1,4 +1,4 @@ -import type { GraphErrorDetails } from '../shared/types/graph.types'; +import type { GraphErrorDetails } from '@agyn/shared'; export class GraphError extends Error implements GraphErrorDetails { code: string; diff --git a/packages/platform-server/src/notifications/notifications.broker.ts b/packages/platform-server/src/notifications/notifications.broker.ts index d6649c859..86fd4b028 100644 --- a/packages/platform-server/src/notifications/notifications.broker.ts +++ b/packages/platform-server/src/notifications/notifications.broker.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import type { NotificationEnvelope } from '../shared/types/notifications'; +import type { NotificationEnvelope } from '@agyn/shared'; import { ConfigService } from '../core/services/config.service'; @Injectable() diff --git a/packages/platform-server/src/notifications/notifications.publisher.ts b/packages/platform-server/src/notifications/notifications.publisher.ts index b260a2a5d..94fbb528a 100644 --- a/packages/platform-server/src/notifications/notifications.publisher.ts +++ b/packages/platform-server/src/notifications/notifications.publisher.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import type { NotificationEnvelope, NotificationRoom } from '../shared/types/notifications'; +import type { NotificationEnvelope, NotificationRoom } from '@agyn/shared'; import { z } from 'zod'; import { LiveGraphRuntime } from '../graph-core/liveGraph.manager'; import { ThreadsMetricsService } from '../agents/threads.metrics.service'; diff --git a/packages/platform-server/src/shared/shared.module.ts b/packages/platform-server/src/shared/shared.module.ts index 1e906f8ef..0780eee10 100644 --- a/packages/platform-server/src/shared/shared.module.ts +++ b/packages/platform-server/src/shared/shared.module.ts @@ -2,6 +2,3 @@ import { Module } from '@nestjs/common'; @Module({}) export class SharedModule {} - -export * from './types/graph.types'; -export * from './types/common.types'; diff --git a/packages/platform-server/src/shared/types/graph.types.ts b/packages/platform-server/src/shared/types/graph.types.ts deleted file mode 100644 index c5c6bef7b..000000000 --- a/packages/platform-server/src/shared/types/graph.types.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Palette schema types (capabilities/staticConfigSchema removed per Issue #451) - -// Core type declarations for JSON-driven agent/tool/trigger graph construction -export interface GraphDefinition { - nodes: NodeDef[]; - edges: EdgeDef[]; -} - -export interface NodeDef { - id: string; - data: { - template: string; // template name registered in TemplateRegistry - config?: Record; // optional configuration passed via instance.setConfig - state?: Record; // optional persisted runtime state (per-node) - }; -} - -export interface EdgeDef { - source: string; // node id - sourceHandle: string; // handle name on source instance - target: string; // node id - targetHandle: string; // handle name on target instance -} - -/** - * Deprecated: legacy dependency bag previously passed to factories via runtime. - * Prefer explicit wiring through template factories and constructor params. - * Kept for backward-compat of type signatures; will be removed in a future release. - */ -export type DependencyBag = Record; - -export interface FactoryContext { - // Deprecated: deps were previously injected globally; avoid relying on this. - deps?: DependencyBag; - get: (id: string) => unknown; // access previously instantiated node (must exist already) - nodeId: string; // id of the node currently being instantiated (for namespacing / awareness) -} - -// All factories must return a Configurable instance that implements setConfig -export interface Configurable { - setConfig(cfg: Record): void | Promise; -} - -export type FactoryFn = (ctx: FactoryContext) => Configurable | Promise; - -export interface TemplateRegistryLike { - get(template: string): FactoryFn | undefined; -} - -export type EndpointType = 'method' | 'property' | 'self'; - -export interface EndpointBase { - type: EndpointType; -} - -export interface MethodEndpoint extends EndpointBase { - type: 'method'; - key: string; - fn: (...args: unknown[]) => unknown | Promise; - owner: unknown; -} - -export interface PropertyEndpoint extends EndpointBase { - type: 'property'; - key: string; - owner: unknown; -} - -export interface SelfEndpoint extends EndpointBase { - type: 'self'; - owner: unknown; -} - -export type Endpoint = MethodEndpoint | PropertyEndpoint | SelfEndpoint; - -export interface GraphBuilderOptions { - continueOnError?: boolean; // if true collects errors and proceeds, else fail-fast - warnOnMissingSetConfig?: boolean; // log / collect a warning when config provided but setConfig missing -} - -export interface GraphErrorDetails { - code: string; - message: string; - nodeId?: string; - edgeIndex?: number; - handle?: string; - template?: string; - cause?: unknown; -} - -export interface GraphBuildResult { - instances: Record; - errors: TError[]; -} - -// Introspection of TemplateRegistry for UI palette generation -export type TemplateKind = 'trigger' | 'agent' | 'tool' | 'mcp' | 'service'; -export interface TemplateNodeSchema { - name: string; // template name (technical identifier) - title: string; // human-readable default title (UI label) - kind: TemplateKind; // node kind for UI badges and grouping - sourcePorts: string[]; // names of source handles (emitters / publishers / tool collections) - targetPorts: string[]; // names of target handles (accept dependencies / tools / instances) -} - -// Persistence layer graph representation (includes optimistic locking fields) -export interface PersistedGraphNode { - id: string; - template: string; - config?: Record; - state?: Record; - position?: { x: number; y: number }; // UI hint, optional server side -} -export interface PersistedGraphEdge { - id?: string; // optional client-provided id (not used for runtime diff) - source: string; - sourceHandle: string; - target: string; - targetHandle: string; -} -export interface PersistedGraph { - name: string; // unique graph name used for Git commits - version: number; // optimistic lock version - updatedAt: string; // ISO timestamp - nodes: PersistedGraphNode[]; - edges: PersistedGraphEdge[]; - // Optional graph-level variables (Issue #543) - // Keys must be unique; values are plain strings. - variables?: Array<{ key: string; value: string }>; -} -export interface PersistedGraphUpsertRequest { - name: string; - version?: number; // expected version (undefined => create) - nodes: PersistedGraphNode[]; - edges: PersistedGraphEdge[]; - // Optional variables; if omitted, repositories must preserve existing values. - variables?: Array<{ key: string; value: string }>; -} -export type PersistedGraphUpsertResponse = PersistedGraph; diff --git a/packages/platform-server/src/shared/types/notifications.ts b/packages/platform-server/src/shared/types/notifications.ts deleted file mode 100644 index 07b6cba32..000000000 --- a/packages/platform-server/src/shared/types/notifications.ts +++ /dev/null @@ -1,28 +0,0 @@ -export const NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; - -export type NotificationSource = 'platform-server'; - -export type StaticNotificationRoom = 'graph' | 'threads'; -export type ThreadNotificationRoom = `thread:${string}`; -export type RunNotificationRoom = `run:${string}`; -export type NodeNotificationRoom = `node:${string}`; -export type NotificationRoom = - | StaticNotificationRoom - | ThreadNotificationRoom - | RunNotificationRoom - | NodeNotificationRoom; - -export type NotificationEnvelope = { - id: string; - ts: string; - source: NotificationSource; - rooms: NotificationRoom[]; - event: EventName; - payload: Payload; -}; - -export type NotificationPublishRequest = { - rooms: NotificationRoom[]; - event: EventName; - payload: Payload; -}; diff --git a/packages/platform-server/src/utils/references.ts b/packages/platform-server/src/utils/references.ts index abe8ca6ed..fdccbb2ea 100644 --- a/packages/platform-server/src/utils/references.ts +++ b/packages/platform-server/src/utils/references.ts @@ -1,51 +1,22 @@ -export type SecretRef = { - kind: 'vault'; - path: string; - key: string; - mount?: string | null; -}; - -export type VariableRef = { - kind: 'var'; - name: string; - default?: string | null; -}; - -export type Reference = SecretRef | VariableRef; - -export type ReferenceSource = Reference['kind']; - -export type ReferenceValue = T | Reference; - -export type ResolutionEventSource = 'secret' | 'variable'; - -export type ResolutionErrorCode = - | 'unresolved_reference' - | 'provider_missing' - | 'permission_denied' - | 'invalid_reference' - | 'type_mismatch' - | 'max_depth_exceeded' - | 'cycle_detected'; - -export type ResolutionEvent = { - path: string; - source: ResolutionEventSource; - cacheHit: boolean; - resolved?: boolean; - error?: { code: ResolutionErrorCode; message: string }; -}; - -export type ResolutionReport = { - events: ResolutionEvent[]; - counts: { - total: number; - resolved: number; - unresolved: number; - cacheHits: number; - errors: number; - }; -}; +import type { + Reference, + ResolutionErrorCode, + ResolutionEvent, + ResolutionEventSource, + ResolutionReport, + SecretRef, + VariableRef, +} from '@agyn/shared'; + +export type { + Reference, + ResolutionErrorCode, + ResolutionEvent, + ResolutionEventSource, + ResolutionReport, + SecretRef, + VariableRef, +} from '@agyn/shared'; export type SecretProvider = (ref: SecretRef) => Promise; diff --git a/packages/platform-server/tsconfig.json b/packages/platform-server/tsconfig.json index 86eb501df..33310d2c5 100644 --- a/packages/platform-server/tsconfig.json +++ b/packages/platform-server/tsconfig.json @@ -12,11 +12,7 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true, "types": ["node"], - "baseUrl": ".", - "paths": { - "@agyn/shared": ["../shared/src/index.ts"], - "@agyn/shared/*": ["../shared/src/*"] - } + "baseUrl": "." }, "include": ["src"], "exclude": ["node_modules"] diff --git a/packages/shared/package.json b/packages/shared/package.json index e3a13ecdb..ae49a13c2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,19 @@ "name": "@agyn/shared", "version": "0.0.1", "private": true, - "main": "src/index.ts", - "types": "src/index.ts" + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepare": "pnpm run build" + }, + "dependencies": { + "@langchain/core": "1.0.1" + } } diff --git a/packages/platform-server/src/shared/types/common.types.ts b/packages/shared/src/common.ts similarity index 66% rename from packages/platform-server/src/shared/types/common.types.ts rename to packages/shared/src/common.ts index 596673dc1..d6d323c3c 100644 --- a/packages/platform-server/src/shared/types/common.types.ts +++ b/packages/shared/src/common.ts @@ -3,9 +3,7 @@ import type { BaseMessage } from '@langchain/core/messages'; export type NodeOutput = { summary?: string; messages?: { method: 'replace' | 'append'; items: BaseMessage[] }; - // Signals the graph should terminate this turn (e.g., a tool indicated completion) done?: boolean; - // Restriction enforcement bookkeeping (per turn) restrictionInjectionCount?: number; restrictionInjected?: boolean; }; diff --git a/packages/shared/src/graph.ts b/packages/shared/src/graph.ts new file mode 100644 index 000000000..38a1585af --- /dev/null +++ b/packages/shared/src/graph.ts @@ -0,0 +1,129 @@ +export interface GraphDefinition { + nodes: NodeDef[]; + edges: EdgeDef[]; +} + +export interface NodeDef { + id: string; + data: { + template: string; + config?: Record; + state?: Record; + }; +} + +export interface EdgeDef { + source: string; + sourceHandle: string; + target: string; + targetHandle: string; +} + +export type DependencyBag = Record; + +export interface FactoryContext { + deps?: DependencyBag; + get: (id: string) => unknown; + nodeId: string; +} + +export interface Configurable { + setConfig(cfg: Record): void | Promise; +} + +export type FactoryFn = (ctx: FactoryContext) => Configurable | Promise; + +export interface TemplateRegistryLike { + get(template: string): FactoryFn | undefined; +} + +export type EndpointType = 'method' | 'property' | 'self'; + +export interface EndpointBase { + type: EndpointType; +} + +export interface MethodEndpoint extends EndpointBase { + type: 'method'; + key: string; + fn: (...args: unknown[]) => unknown | Promise; + owner: unknown; +} + +export interface PropertyEndpoint extends EndpointBase { + type: 'property'; + key: string; + owner: unknown; +} + +export interface SelfEndpoint extends EndpointBase { + type: 'self'; + owner: unknown; +} + +export type Endpoint = MethodEndpoint | PropertyEndpoint | SelfEndpoint; + +export interface GraphBuilderOptions { + continueOnError?: boolean; + warnOnMissingSetConfig?: boolean; +} + +export interface GraphErrorDetails { + code: string; + message: string; + nodeId?: string; + edgeIndex?: number; + handle?: string; + template?: string; + cause?: unknown; +} + +export interface GraphBuildResult { + instances: Record; + errors: TError[]; +} + +export type TemplateKind = 'trigger' | 'agent' | 'tool' | 'mcp' | 'service'; + +export interface TemplateNodeSchema { + name: string; + title: string; + kind: TemplateKind; + sourcePorts: string[]; + targetPorts: string[]; +} + +export interface PersistedGraphNode { + id: string; + template: string; + config?: Record; + state?: Record; + position?: { x: number; y: number }; +} + +export interface PersistedGraphEdge { + id?: string; + source: string; + sourceHandle: string; + target: string; + targetHandle: string; +} + +export interface PersistedGraph { + name: string; + version: number; + updatedAt: string; + nodes: PersistedGraphNode[]; + edges: PersistedGraphEdge[]; + variables?: Array<{ key: string; value: string }>; +} + +export interface PersistedGraphUpsertRequest { + name: string; + version?: number; + nodes: PersistedGraphNode[]; + edges: PersistedGraphEdge[]; + variables?: Array<{ key: string; value: string }>; +} + +export type PersistedGraphUpsertResponse = PersistedGraph; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ccaa99c89..31d248dbf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,23 +1,4 @@ -// Re-export selected shared types from server graph layer to avoid duplication -export type { - TemplateNodeSchema, - PersistedGraph, - PersistedGraphNode, - PersistedGraphEdge, - PersistedGraphUpsertRequest, - PersistedGraphUpsertResponse, -} from '../../platform-server/src/shared/types/graph.types'; - -export type { - SecretRef, - VariableRef, - Reference, - ReferenceSource, - ReferenceValue, - ResolutionEvent, - ResolutionReport, - ResolutionEventSource, - ResolutionErrorCode, -} from './references'; - +export * from './graph'; +export * from './common'; +export * from './references'; export * from './notifications'; diff --git a/packages/shared/src/notifications/index.ts b/packages/shared/src/notifications/index.ts index df8f0f010..07b6cba32 100644 --- a/packages/shared/src/notifications/index.ts +++ b/packages/shared/src/notifications/index.ts @@ -1 +1,28 @@ -export * from '../../../platform-server/src/shared/types/notifications'; +export const NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; + +export type NotificationSource = 'platform-server'; + +export type StaticNotificationRoom = 'graph' | 'threads'; +export type ThreadNotificationRoom = `thread:${string}`; +export type RunNotificationRoom = `run:${string}`; +export type NodeNotificationRoom = `node:${string}`; +export type NotificationRoom = + | StaticNotificationRoom + | ThreadNotificationRoom + | RunNotificationRoom + | NodeNotificationRoom; + +export type NotificationEnvelope = { + id: string; + ts: string; + source: NotificationSource; + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; + +export type NotificationPublishRequest = { + rooms: NotificationRoom[]; + event: EventName; + payload: Payload; +}; diff --git a/packages/shared/src/references.ts b/packages/shared/src/references.ts index 87c940f16..bea092585 100644 --- a/packages/shared/src/references.ts +++ b/packages/shared/src/references.ts @@ -1,11 +1,48 @@ -export type { - Reference, - ReferenceSource, - ReferenceValue, - ResolutionErrorCode, - ResolutionEvent, - ResolutionEventSource, - ResolutionReport, - SecretRef, - VariableRef, -} from '../../platform-server/src/utils/references'; +export type SecretRef = { + kind: 'vault'; + path: string; + key: string; + mount?: string | null; +}; + +export type VariableRef = { + kind: 'var'; + name: string; + default?: string | null; +}; + +export type Reference = SecretRef | VariableRef; + +export type ReferenceSource = Reference['kind']; + +export type ReferenceValue = T | Reference; + +export type ResolutionEventSource = 'secret' | 'variable'; + +export type ResolutionErrorCode = + | 'unresolved_reference' + | 'provider_missing' + | 'permission_denied' + | 'invalid_reference' + | 'type_mismatch' + | 'max_depth_exceeded' + | 'cycle_detected'; + +export type ResolutionEvent = { + path: string; + source: ResolutionEventSource; + cacheHit: boolean; + resolved?: boolean; + error?: { code: ResolutionErrorCode; message: string }; +}; + +export type ResolutionReport = { + events: ResolutionEvent[]; + counts: { + total: number; + resolved: number; + unresolved: number; + cacheHits: number; + errors: number; + }; +}; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 000000000..469221238 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b327432af..53544c2fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -734,7 +734,11 @@ importers: specifier: ^4.1.12 version: 4.1.12 - packages/shared: {} + packages/shared: + dependencies: + '@langchain/core': + specifier: 1.0.1 + version: 1.0.1(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.6.0(ws@8.18.3)(zod@4.1.12)) packages: From 72f8f55e1cebfd1b4b61c29b3c30ffd410bae6ff Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 21:37:47 +0000 Subject: [PATCH 05/15] fix(notifications): inline channel default --- packages/notifications-gateway/src/config.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/notifications-gateway/src/config.ts b/packages/notifications-gateway/src/config.ts index 6d6af9eb7..89016ba2a 100644 --- a/packages/notifications-gateway/src/config.ts +++ b/packages/notifications-gateway/src/config.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; -import { NOTIFICATIONS_CHANNEL } from '@agyn/shared'; + +const DEFAULT_NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; const configSchema = z.object({ port: z @@ -23,7 +24,7 @@ const configSchema = z.object({ .refine((value) => value.startsWith('redis://') || value.startsWith('rediss://'), { message: 'REDIS_URL must start with redis:// or rediss://', }), - redisChannel: z.string().min(1).default(NOTIFICATIONS_CHANNEL), + redisChannel: z.string().min(1).default(DEFAULT_NOTIFICATIONS_CHANNEL), logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), }); @@ -35,7 +36,7 @@ export function loadConfig(): GatewayConfig { host: process.env.HOST, socketPath: process.env.SOCKET_IO_PATH, redisUrl: process.env.REDIS_URL, - redisChannel: process.env.NOTIFICATIONS_CHANNEL, + redisChannel: process.env.NOTIFICATIONS_CHANNEL ?? DEFAULT_NOTIFICATIONS_CHANNEL, logLevel: process.env.LOG_LEVEL, }); } From 90b666b0a97655eb9b46525873131114bb273b44 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 22:28:07 +0000 Subject: [PATCH 06/15] fix(notifications): autoload gateway env --- packages/notifications-gateway/README.md | 5 ++++- packages/notifications-gateway/package.json | 1 + packages/notifications-gateway/src/config.ts | 11 +++++++---- packages/notifications-gateway/src/index.ts | 2 +- pnpm-lock.yaml | 14 ++++---------- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/notifications-gateway/README.md b/packages/notifications-gateway/README.md index 2f86883df..46ef58a7f 100644 --- a/packages/notifications-gateway/README.md +++ b/packages/notifications-gateway/README.md @@ -12,10 +12,13 @@ Socket.IO server. | `PORT` | TCP port for the HTTP/WebSocket server | `3011` | | `HOST` | Bind address | `0.0.0.0` | | `SOCKET_IO_PATH` | Socket.IO path (must remain `/socket.io` for the UI) | `/socket.io` | -| `REDIS_URL` | Redis connection string (e.g. `redis://redis:6379/0`) | _required_ | +| `NOTIFICATIONS_REDIS_URL` | Redis connection string (e.g. `redis://redis:6379/0`) | _required_ | | `NOTIFICATIONS_CHANNEL` | Pub/Sub channel name | `notifications.v1` | | `LOG_LEVEL` | Pino log level (`fatal`..`trace`) | `info` | +The gateway automatically loads environment variables from a local `.env` file via `dotenv`, +matching the behavior of other platform services. + ## Development ```bash diff --git a/packages/notifications-gateway/package.json b/packages/notifications-gateway/package.json index b9c6647fc..274b05aa7 100644 --- a/packages/notifications-gateway/package.json +++ b/packages/notifications-gateway/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@agyn/shared": "workspace:*", + "dotenv": "17.2.2", "ioredis": "^5.4.1", "pino": "^10.1.0", "socket.io": "^4.8.1", diff --git a/packages/notifications-gateway/src/config.ts b/packages/notifications-gateway/src/config.ts index 89016ba2a..e6ea0bab6 100644 --- a/packages/notifications-gateway/src/config.ts +++ b/packages/notifications-gateway/src/config.ts @@ -1,5 +1,8 @@ +import * as dotenv from 'dotenv'; import { z } from 'zod'; +dotenv.config(); + const DEFAULT_NOTIFICATIONS_CHANNEL = 'notifications.v1' as const; const configSchema = z.object({ @@ -18,11 +21,11 @@ const configSchema = z.object({ .string() .default('/socket.io') .transform((value) => (value.startsWith('/') ? value : `/${value}`)), - redisUrl: z + notificationsRedisUrl: z .string() - .min(1, 'REDIS_URL is required') + .min(1, 'NOTIFICATIONS_REDIS_URL is required') .refine((value) => value.startsWith('redis://') || value.startsWith('rediss://'), { - message: 'REDIS_URL must start with redis:// or rediss://', + message: 'NOTIFICATIONS_REDIS_URL must start with redis:// or rediss://', }), redisChannel: z.string().min(1).default(DEFAULT_NOTIFICATIONS_CHANNEL), logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), @@ -35,7 +38,7 @@ export function loadConfig(): GatewayConfig { port: process.env.PORT, host: process.env.HOST, socketPath: process.env.SOCKET_IO_PATH, - redisUrl: process.env.REDIS_URL, + notificationsRedisUrl: process.env.NOTIFICATIONS_REDIS_URL, redisChannel: process.env.NOTIFICATIONS_CHANNEL ?? DEFAULT_NOTIFICATIONS_CHANNEL, logLevel: process.env.LOG_LEVEL, }); diff --git a/packages/notifications-gateway/src/index.ts b/packages/notifications-gateway/src/index.ts index 0e1a78a16..e04d18ce0 100644 --- a/packages/notifications-gateway/src/index.ts +++ b/packages/notifications-gateway/src/index.ts @@ -17,7 +17,7 @@ async function main(): Promise { const httpServer = createServer(); const io = createSocketServer({ server: httpServer, path: config.socketPath, logger }); const subscriber = new NotificationsSubscriber( - { url: config.redisUrl, channel: config.redisChannel }, + { url: config.notificationsRedisUrl, channel: config.redisChannel }, logger, ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53544c2fc..157772750 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: '@agyn/shared': specifier: workspace:* version: link:../shared + dotenv: + specifier: 17.2.2 + version: 17.2.2 ioredis: specifier: ^5.4.1 version: 5.9.3 @@ -12347,15 +12350,6 @@ snapshots: msw: 2.11.3(@types/node@20.19.19)(typescript@5.9.2) vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.19 - optionalDependencies: - msw: 2.11.3(@types/node@24.5.2)(typescript@5.8.3) - vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -17344,7 +17338,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From 6241d9ad2529819f08133934b7b8a7a825dc5d4a Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 19 Feb 2026 23:00:34 +0000 Subject: [PATCH 07/15] chore(devops): share redis across compose --- README.md | 6 +++++- docker-compose.e2e.yml | 11 ++++------- docker-compose.yml | 9 +++++++++ docs/runbooks/notifications-gateway.md | 12 ++++++++++++ 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ac3e7577c..d50142442 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Required versions: Optional local services (provided in docker-compose.yml for dev): - Postgres databases (postgres at 5442, agents-db at 5443) - LiteLLM + Postgres (loopback port 4000) +- Redis (6379) for notifications Pub/Sub - Vault (8200) with dev auto-init - NCPS (Nix cache proxy) on 8501 - Prometheus (9090), Grafana (3000), cAdvisor (8080) @@ -117,9 +118,12 @@ pnpm install ```bash docker compose up -d # Starts postgres (5442), agents-db (5443), vault (8200), ncps (8501), -# litellm (127.0.0.1:4000), docker-runner (7071) +# litellm (127.0.0.1:4000), docker-runner (7071), redis (6379) # Optional monitoring (prometheus/grafana) lives in docker-compose.monitoring.yml. # Enable with: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d + +# To launch only Redis for notifications fan-out: +docker compose up -d redis ``` 4) Apply server migrations and generate Prisma client: diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 0288806e4..82400f31a 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -23,12 +23,9 @@ services: - agents_net redis: - image: redis:7-alpine - restart: unless-stopped - ports: - - "6379:6379" - networks: - - agents_net + extends: + file: ./docker-compose.yml + service: redis litellm-db: image: postgres:16-alpine @@ -128,7 +125,7 @@ services: environment: PORT: 3011 HOST: 0.0.0.0 - REDIS_URL: redis://redis:6379/0 + NOTIFICATIONS_REDIS_URL: redis://redis:6379/0 NOTIFICATIONS_CHANNEL: ${NOTIFICATIONS_CHANNEL:-notifications.v1} SOCKET_IO_PATH: /socket.io networks: diff --git a/docker-compose.yml b/docker-compose.yml index 4f438a897..369bf72a4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,15 @@ services: timeout: 5s retries: 5 + redis: + image: redis:7-alpine + container_name: redis + restart: unless-stopped + ports: + - "6379:6379" + networks: + - agents_net + vault: image: hashicorp/vault:1.17 oom_score_adj: -900 diff --git a/docs/runbooks/notifications-gateway.md b/docs/runbooks/notifications-gateway.md index 9c7bfee34..235ef3257 100644 --- a/docs/runbooks/notifications-gateway.md +++ b/docs/runbooks/notifications-gateway.md @@ -23,6 +23,18 @@ server with Envoy and exposes the Socket.IO endpoint via the standalone - Compose v2 (`docker compose version`) - Ports `8080`, `9901`, `4000`, `5443`, and `6379` available on the host +## Redis for local development + +If you run the platform server or notifications gateway outside the full +Envoy/E2E stack, start the shared Redis dependency first: + +``` +docker compose up -d redis +``` + +The E2E compose file reuses the same service definition, so Redis does not need +separate configuration. + ## Start the stack ``` From 09fcd26dafb310051bc09c408fa5b98dac277dfc Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 20 Feb 2026 00:13:46 +0000 Subject: [PATCH 08/15] fix(notifications): harden broker injection --- .../src/notifications/notifications.broker.ts | 5 +++-- .../src/notifications/notifications.module.ts | 3 ++- .../src/notifications/notifications.publisher.ts | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/platform-server/src/notifications/notifications.broker.ts b/packages/platform-server/src/notifications/notifications.broker.ts index 86fd4b028..81529d3ac 100644 --- a/packages/platform-server/src/notifications/notifications.broker.ts +++ b/packages/platform-server/src/notifications/notifications.broker.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import type { NotificationEnvelope } from '@agyn/shared'; import { ConfigService } from '../core/services/config.service'; @@ -9,7 +9,8 @@ export class NotificationsBroker { private readonly channel: string; private connected = false; - constructor(private readonly config: ConfigService) { + constructor(@Inject(ConfigService) private readonly config: ConfigService) { + ConfigService.assertInitialized(config); this.channel = config.notificationsChannel; this.redis = new Redis(config.notificationsRedisUrl, { lazyConnect: true, diff --git a/packages/platform-server/src/notifications/notifications.module.ts b/packages/platform-server/src/notifications/notifications.module.ts index eb05549ef..5b248b3a2 100644 --- a/packages/platform-server/src/notifications/notifications.module.ts +++ b/packages/platform-server/src/notifications/notifications.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { GraphApiModule } from '../graph/graph-api.module'; import { EventsModule } from '../events/events.module'; +import { CoreModule } from '../core/core.module'; import { NotificationsPublisher } from './notifications.publisher'; import { NotificationsBroker } from './notifications.broker'; @Module({ - imports: [GraphApiModule, EventsModule], + imports: [CoreModule, GraphApiModule, EventsModule], providers: [NotificationsPublisher, NotificationsBroker], }) export class NotificationsModule {} diff --git a/packages/platform-server/src/notifications/notifications.publisher.ts b/packages/platform-server/src/notifications/notifications.publisher.ts index 94fbb528a..416dcb33d 100644 --- a/packages/platform-server/src/notifications/notifications.publisher.ts +++ b/packages/platform-server/src/notifications/notifications.publisher.ts @@ -47,7 +47,7 @@ export class NotificationsPublisher implements OnModuleInit, OnModuleDestroy { @Inject(ThreadsMetricsService) private readonly metrics: ThreadsMetricsService, @Inject(PrismaService) private readonly prismaService: PrismaService, @Inject(EventsBusService) private readonly eventsBus: EventsBusService, - private readonly broker: NotificationsBroker, + @Inject(NotificationsBroker) private readonly broker: NotificationsBroker, ) {} async onModuleInit(): Promise { From bc74ca3fabb3695d874b4431e8d3936e11f9ca2e Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 20 Feb 2026 00:53:51 +0000 Subject: [PATCH 09/15] test(notifications): expand module and gateway coverage --- .../notifications-gateway/src/config.test.ts | 49 +++++++ .../src/dispatch.test.ts | 20 +++ .../redis/notifications-subscriber.test.ts | 18 +++ .../__tests__/notifications.broker.test.ts | 61 ++++++++ .../notifications.module.smoke.test.ts | 132 ++++++++++++++++++ .../notifications.publisher.bus.test.ts | 99 ++++++++++--- .../__tests__/notifications.test-helpers.ts | 50 +++++++ 7 files changed, 411 insertions(+), 18 deletions(-) create mode 100644 packages/notifications-gateway/src/config.test.ts create mode 100644 packages/platform-server/__tests__/notifications.broker.test.ts create mode 100644 packages/platform-server/__tests__/notifications.module.smoke.test.ts create mode 100644 packages/platform-server/__tests__/notifications.test-helpers.ts diff --git a/packages/notifications-gateway/src/config.test.ts b/packages/notifications-gateway/src/config.test.ts new file mode 100644 index 000000000..7af2e97de --- /dev/null +++ b/packages/notifications-gateway/src/config.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfig } from './config'; + +const TRACKED_KEYS = ['PORT', 'HOST', 'SOCKET_IO_PATH', 'NOTIFICATIONS_REDIS_URL', 'NOTIFICATIONS_CHANNEL', 'LOG_LEVEL'] as const; +const snapshots: Record = {}; + +beforeEach(() => { + for (const key of TRACKED_KEYS) { + snapshots[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of TRACKED_KEYS) { + const value = snapshots[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe('loadConfig', () => { + it('loads redis configuration and overrides defaults from env', () => { + process.env.NOTIFICATIONS_REDIS_URL = 'redis://127.0.0.1:6380/1'; + process.env.NOTIFICATIONS_CHANNEL = 'notifications.v2'; + process.env.PORT = '4000'; + process.env.SOCKET_IO_PATH = 'socket'; + process.env.LOG_LEVEL = 'debug'; + + const config = loadConfig(); + + expect(config.port).toBe(4000); + expect(config.socketPath).toBe('/socket'); + expect(config.notificationsRedisUrl).toBe('redis://127.0.0.1:6380/1'); + expect(config.redisChannel).toBe('notifications.v2'); + expect(config.logLevel).toBe('debug'); + }); + + it('falls back to the default notifications channel when unset', () => { + process.env.NOTIFICATIONS_REDIS_URL = 'redis://127.0.0.1:6379/0'; + + const config = loadConfig(); + + expect(config.redisChannel).toBe('notifications.v1'); + }); +}); diff --git a/packages/notifications-gateway/src/dispatch.test.ts b/packages/notifications-gateway/src/dispatch.test.ts index ab203b23f..d42a59b88 100644 --- a/packages/notifications-gateway/src/dispatch.test.ts +++ b/packages/notifications-gateway/src/dispatch.test.ts @@ -58,4 +58,24 @@ describe('dispatchToRooms', () => { ); expect(emit).toHaveBeenCalledTimes(envelope.rooms.length); }); + + it('dispatches run status events to both thread and run rooms', () => { + const emit = vi.fn(); + const to = vi.fn(() => ({ emit })); + const io = { to } as unknown as SocketIOServer; + const logger = createLogger(); + const envelope: NotificationEnvelope = { + ...createEnvelope(), + event: 'run_status_changed', + rooms: ['thread:abc123', 'run:run-123'], + payload: { threadId: 'abc123', runId: 'run-123', status: 'running' }, + }; + + dispatchToRooms(io, envelope, logger); + + expect(to).toHaveBeenNthCalledWith(1, 'thread:abc123'); + expect(to).toHaveBeenNthCalledWith(2, 'run:run-123'); + expect(emit).toHaveBeenCalledTimes(2); + expect(logger.warn).not.toHaveBeenCalled(); + }); }); diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts index 11e2f19be..d09af1e7f 100644 --- a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts @@ -90,4 +90,22 @@ describe('NotificationsSubscriber', () => { ); expect(errorSpy).toHaveBeenCalledWith(failure); }); + + it('emits error events when Redis errors after subscribing', async () => { + const logger = createLogger(); + const subscriber = new NotificationsSubscriber(options, logger); + const errorSpy = vi.fn(); + subscriber.on('error', errorSpy); + + await subscriber.start(); + const runtimeError = new Error('redis down'); + redis.emit('error', runtimeError); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ error: { name: 'Error', message: runtimeError.message } }), + 'redis subscriber error', + ); + expect(errorSpy).toHaveBeenCalledWith(runtimeError); + await subscriber.stop(); + }); }); diff --git a/packages/platform-server/__tests__/notifications.broker.test.ts b/packages/platform-server/__tests__/notifications.broker.test.ts new file mode 100644 index 000000000..ac50e2a90 --- /dev/null +++ b/packages/platform-server/__tests__/notifications.broker.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { ConfigService } from '../src/core/services/config.service'; +import { NotificationsBroker } from '../src/notifications/notifications.broker'; +import { initNotificationsConfig, resetNotificationsConfig } from './notifications.test-helpers'; + +class RedisStub { + connect = vi.fn(async () => {}); + publish = vi.fn(async () => 1); + quit = vi.fn(async () => {}); +} + +const redisFactory = vi.fn(() => new RedisStub()); + +vi.mock('ioredis', () => ({ + default: vi.fn((...args: unknown[]) => redisFactory(...args)), +})); + +describe('NotificationsBroker', () => { + let envSnapshot: EnvSnapshot; + + beforeEach(() => { + envSnapshot = initNotificationsConfig(); + redisFactory.mockImplementation(() => new RedisStub()); + }); + + afterEach(() => { + resetNotificationsConfig(envSnapshot); + vi.clearAllMocks(); + }); + + it('connects to Redis using configured URL and channel', async () => { + const broker = new NotificationsBroker(ConfigService.getInstance()); + await broker.connect(); + + const redis = redisFactory.mock.results[0]?.value as RedisStub | undefined; + expect(redis).toBeDefined(); + expect(redis?.connect).toHaveBeenCalledTimes(1); + expect(redisFactory).toHaveBeenCalledWith('redis://localhost:6379/0', expect.objectContaining({ lazyConnect: true })); + + const envelope: NotificationEnvelope = { + id: 'evt-1', + ts: new Date('2024-01-01T00:00:00.000Z').toISOString(), + source: 'platform-server', + rooms: ['thread:abc'], + event: 'thread_updated', + payload: { threadId: 'thread-abc' }, + }; + await broker.publish(envelope); + + expect(redis.publish).toHaveBeenCalledWith('notifications.test', JSON.stringify(envelope)); + await broker.close(); + }); + + it('propagates validation errors when NOTIFICATIONS_REDIS_URL is missing', () => { + delete process.env.NOTIFICATIONS_REDIS_URL; + ConfigService.clearInstanceForTest(); + + expect(() => ConfigService.fromEnv()).toThrow(/notificationsRedisUrl/i); + }); +}); diff --git a/packages/platform-server/__tests__/notifications.module.smoke.test.ts b/packages/platform-server/__tests__/notifications.module.smoke.test.ts new file mode 100644 index 000000000..b9db5ca92 --- /dev/null +++ b/packages/platform-server/__tests__/notifications.module.smoke.test.ts @@ -0,0 +1,132 @@ +import { Global, Module } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoreModule } from '../src/core/core.module'; +import { NotificationsModule } from '../src/notifications/notifications.module'; +import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; +import { NotificationsBroker } from '../src/notifications/notifications.broker'; +import { LiveGraphRuntime } from '../src/graph-core/liveGraph.manager'; +import { ThreadsMetricsService } from '../src/agents/threads.metrics.service'; +import { PrismaService } from '../src/core/services/prisma.service'; +import { EventsBusService } from '../src/events/events-bus.service'; +import type { EnvSnapshot } from './notifications.test-helpers'; +import { initNotificationsConfig, resetNotificationsConfig } from './notifications.test-helpers'; + +const createStubModule = vi.hoisted(() => { + return (name: string): unknown => { + class NamedModule {} + Module({})(NamedModule); + Object.defineProperty(NamedModule, 'name', { value: name }); + return NamedModule; + }; +}); + +vi.mock('../src/graph/graph-api.module', () => ({ + GraphApiModule: createStubModule('GraphApiModuleStub'), +})); + +vi.mock('../src/events/events.module', () => ({ + EventsModule: createStubModule('EventsModuleStub'), +})); + +class RedisStub { + connect = vi.fn(async () => {}); + publish = vi.fn(async () => 1); + quit = vi.fn(async () => {}); +} + +const redisFactory = vi.fn(() => new RedisStub()); + +vi.mock('ioredis', () => ({ + default: vi.fn((...args: unknown[]) => redisFactory(...args)), +})); + +const createEventsBusStub = (): EventsBusService => { + const disposer = () => vi.fn(); + return { + subscribeToRunEvents: () => disposer(), + subscribeToToolOutputChunk: () => disposer(), + subscribeToToolOutputTerminal: () => disposer(), + subscribeToReminderCount: () => disposer(), + subscribeToNodeState: () => disposer(), + subscribeToThreadCreated: () => disposer(), + subscribeToThreadUpdated: () => disposer(), + subscribeToMessageCreated: () => disposer(), + subscribeToRunStatusChanged: () => disposer(), + subscribeToThreadMetrics: () => disposer(), + subscribeToThreadMetricsAncestors: () => disposer(), + emitToolOutputChunk: vi.fn(), + emitToolOutputTerminal: vi.fn(), + emitReminderCount: vi.fn(), + emitNodeState: vi.fn(), + emitThreadCreated: vi.fn(), + emitThreadUpdated: vi.fn(), + emitMessageCreated: vi.fn(), + emitRunStatusChanged: vi.fn(), + } as unknown as EventsBusService; +}; + +const runtimeRef: { current: LiveGraphRuntime | null } = { current: null }; +const metricsRef: { current: ThreadsMetricsService | null } = { current: null }; +const prismaRef: { current: PrismaService | null } = { current: null }; +const eventsBusRef: { current: EventsBusService | null } = { current: null }; + +@Global() +@Module({ + providers: [ + { provide: LiveGraphRuntime, useFactory: () => runtimeRef.current }, + { provide: ThreadsMetricsService, useFactory: () => metricsRef.current }, + { provide: PrismaService, useFactory: () => prismaRef.current }, + { provide: EventsBusService, useFactory: () => eventsBusRef.current }, + ], + exports: [LiveGraphRuntime, ThreadsMetricsService, PrismaService, EventsBusService], +}) +class NotificationsTestOverridesModule {} + +describe('NotificationsModule bootstrap', () => { + let envSnapshot: EnvSnapshot; + + beforeEach(() => { + envSnapshot = initNotificationsConfig(); + redisFactory.mockImplementation(() => new RedisStub()); + }); + + afterEach(async () => { + resetNotificationsConfig(envSnapshot); + runtimeRef.current = null; + metricsRef.current = null; + prismaRef.current = null; + eventsBusRef.current = null; + vi.clearAllMocks(); + }); + + it('initializes the module and connects the broker', async () => { + const runtimeStub = { subscribe: vi.fn(() => () => {}) } as unknown as LiveGraphRuntime; + const metricsStub = { getThreadsMetrics: vi.fn(async () => ({})) } as unknown as ThreadsMetricsService; + const prismaStub = { + getClient: () => ({ $queryRaw: vi.fn().mockResolvedValue([]) }), + } as unknown as PrismaService; + const eventsBusStub = createEventsBusStub(); + runtimeRef.current = runtimeStub; + metricsRef.current = metricsStub; + prismaRef.current = prismaStub; + eventsBusRef.current = eventsBusStub; + + const testingModule = await Test.createTestingModule({ + imports: [CoreModule, NotificationsTestOverridesModule, NotificationsModule], + }).compile(); + + const broker = testingModule.get(NotificationsBroker); + const connectSpy = vi.spyOn(broker, 'connect'); + + await testingModule.init(); + + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(redisFactory).toHaveBeenCalledWith('redis://localhost:6379/0', expect.objectContaining({ lazyConnect: true })); + + const publisher = testingModule.get(NotificationsPublisher); + expect(publisher).toBeInstanceOf(NotificationsPublisher); + + await testingModule.close(); + }); +}); diff --git a/packages/platform-server/__tests__/notifications.publisher.bus.test.ts b/packages/platform-server/__tests__/notifications.publisher.bus.test.ts index f1a0acef3..5a763f486 100644 --- a/packages/platform-server/__tests__/notifications.publisher.bus.test.ts +++ b/packages/platform-server/__tests__/notifications.publisher.bus.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NotificationEnvelope } from '@agyn/shared'; import type { EventsBusService, ReminderCountEvent, @@ -6,6 +7,12 @@ import type { } from '../src/events/events-bus.service'; import type { ToolOutputChunkPayload, ToolOutputTerminalPayload } from '../src/events/run-events.service'; import { NotificationsPublisher } from '../src/notifications/notifications.publisher'; +import { + NodeStateEventSchema, + ReminderCountSocketEventSchema, + ToolOutputChunkEventSchema, + ToolOutputTerminalEventSchema, +} from '../src/notifications/notifications.schemas'; type Handler = ((payload: T) => void) | null; @@ -130,6 +137,12 @@ async function createPublisherTestContext(): Promise { return { publisher, handlers, broker, logger }; } +const findEnvelope = (ctx: PublisherTestContext, event: string): NotificationEnvelope | undefined => { + return ctx.broker.publish.mock.calls + .map(([envelope]) => envelope as NotificationEnvelope) + .find((payload) => payload.event === event); +}; + describe('NotificationsPublisher event bus integration', () => { let ctx: PublisherTestContext; @@ -173,7 +186,7 @@ describe('NotificationsPublisher event bus integration', () => { ); }); - it('converts tool output chunk timestamps to ISO strings', () => { + it('emits validated tool output chunk envelopes', () => { ctx.handlers.chunk?.({ runId: RUN_ID, threadId: THREAD_ID, @@ -185,9 +198,32 @@ describe('NotificationsPublisher event bus integration', () => { data: 'chunk', }); - const envelope = ctx.broker.publish.mock.calls.find(([call]) => (call as { event: string }).event === 'tool_output_chunk')?.[0]; + const envelope = findEnvelope(ctx, 'tool_output_chunk'); + expect(envelope).toBeDefined(); + expect(envelope?.rooms).toEqual(expect.arrayContaining([`run:${RUN_ID}`, `thread:${THREAD_ID}`])); + expect(() => ToolOutputChunkEventSchema.parse(envelope?.payload)).not.toThrow(); + }); + + it('emits validated tool output terminal envelopes', () => { + ctx.handlers.terminal?.({ + runId: RUN_ID, + threadId: THREAD_ID, + eventId: EVENT_ID, + exitCode: 0, + status: 'success', + bytesStdout: 1, + bytesStderr: 0, + totalChunks: 1, + droppedChunks: 0, + ts: '2025-01-01T00:00:01.000Z', + savedPath: '/tmp/stdout', + message: 'done', + }); + + const envelope = findEnvelope(ctx, 'tool_output_terminal'); expect(envelope).toBeDefined(); - expect((envelope as { payload: { ts: string } }).payload.ts).toBe('2025-01-01T00:00:00.000Z'); + expect(envelope?.rooms).toEqual(expect.arrayContaining([`run:${RUN_ID}`, `thread:${THREAD_ID}`])); + expect(() => ToolOutputTerminalEventSchema.parse(envelope?.payload)).not.toThrow(); }); it('logs and skips invalid chunk timestamps', () => { @@ -207,18 +243,48 @@ describe('NotificationsPublisher event bus integration', () => { ); }); - it('publishes reminder counts and schedules thread metrics', () => { + it('publishes reminder counts using validated payload', () => { ctx.handlers.reminder?.({ nodeId: 'node-1', count: 2, threadId: THREAD_ID, updatedAtMs: 10 } as ReminderCountEvent); - expect(ctx.logger.warn).not.toHaveBeenCalled(); - expect(ctx.broker.publish).toHaveBeenCalledWith(expect.objectContaining({ event: 'node_reminder_count' })); + const envelope = findEnvelope(ctx, 'node_reminder_count'); + expect(envelope).toBeDefined(); + expect(() => ReminderCountSocketEventSchema.parse(envelope?.payload)).not.toThrow(); + expect(envelope?.rooms).toEqual(expect.arrayContaining(['graph', 'node:node-1'])); }); - it('publishes node state updates', () => { + it('publishes node state updates via schema validation', () => { ctx.handlers.nodeState?.({ nodeId: 'node-1', state: { foo: 'bar' }, updatedAtMs: 5 }); - expect(ctx.broker.publish).toHaveBeenCalledWith(expect.objectContaining({ event: 'node_state' })); + const envelope = findEnvelope(ctx, 'node_state'); + expect(envelope).toBeDefined(); + expect(() => NodeStateEventSchema.parse(envelope?.payload)).not.toThrow(); + }); + + it('publishes thread created events to the threads room', () => { + ctx.handlers.threadCreated?.({ + id: THREAD_ID, + alias: 'Demo Thread', + summary: null, + status: 'running', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + } as any); + const envelope = findEnvelope(ctx, 'thread_created'); + expect(envelope).toBeDefined(); + expect(envelope?.rooms).toEqual(['threads']); }); - it('publishes message created events', () => { + it('publishes thread updated events to the threads room', () => { + ctx.handlers.threadUpdated?.({ + id: THREAD_ID, + alias: 'Demo Thread', + summary: 'updated', + status: 'running', + createdAt: new Date('2024-01-01T00:00:00.000Z'), + } as any); + const envelope = findEnvelope(ctx, 'thread_updated'); + expect(envelope).toBeDefined(); + expect(envelope?.rooms).toEqual(['threads']); + }); + + it('publishes message created events to the originating thread', () => { ctx.handlers.messageCreated?.({ threadId: THREAD_ID, message: { @@ -229,9 +295,9 @@ describe('NotificationsPublisher event bus integration', () => { createdAt: new Date('2024-01-01T00:00:00.000Z'), } as any, }); - expect(ctx.broker.publish).toHaveBeenCalledWith( - expect.objectContaining({ event: 'message_created', rooms: expect.arrayContaining([`thread:${THREAD_ID}`]) }), - ); + const envelope = findEnvelope(ctx, 'message_created'); + expect(envelope).toBeDefined(); + expect(envelope?.rooms).toEqual([`thread:${THREAD_ID}`]); }); it('publishes run status updates', () => { @@ -244,11 +310,8 @@ describe('NotificationsPublisher event bus integration', () => { updatedAt: new Date('2024-01-01T00:01:00.000Z'), }, } as any); - expect(ctx.broker.publish).toHaveBeenCalledWith( - expect.objectContaining({ - event: 'run_status_changed', - rooms: expect.arrayContaining([`thread:${THREAD_ID}`, `run:${RUN_ID}`]), - }), - ); + const envelope = findEnvelope(ctx, 'run_status_changed'); + expect(envelope).toBeDefined(); + expect(envelope?.rooms).toEqual(expect.arrayContaining([`thread:${THREAD_ID}`, `run:${RUN_ID}`])); }); }); diff --git a/packages/platform-server/__tests__/notifications.test-helpers.ts b/packages/platform-server/__tests__/notifications.test-helpers.ts new file mode 100644 index 000000000..6f8b24217 --- /dev/null +++ b/packages/platform-server/__tests__/notifications.test-helpers.ts @@ -0,0 +1,50 @@ +import { ConfigService } from '../src/core/services/config.service'; + +export const NOTIFICATIONS_BASE_ENV: Record = { + LLM_PROVIDER: 'litellm', + LITELLM_BASE_URL: 'http://127.0.0.1:4000', + LITELLM_MASTER_KEY: 'sk-test-master', + DOCKER_RUNNER_BASE_URL: 'http://localhost:7071', + DOCKER_RUNNER_SHARED_SECRET: 'test-shared-secret', + NOTIFICATIONS_REDIS_URL: 'redis://localhost:6379/0', + NOTIFICATIONS_CHANNEL: 'notifications.test', + AGENTS_DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/postgres', +}; + +export type EnvSnapshot = Record; + +const applyEnv = (overrides?: Partial>): EnvSnapshot => { + const snapshot: EnvSnapshot = {}; + for (const key of Object.keys(NOTIFICATIONS_BASE_ENV)) { + snapshot[key] = process.env[key]; + } + const next = { ...NOTIFICATIONS_BASE_ENV, ...overrides } as Record; + for (const [key, value] of Object.entries(next)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + return snapshot; +}; + +export const initNotificationsConfig = ( + overrides?: Partial>, +): EnvSnapshot => { + ConfigService.clearInstanceForTest(); + const snapshot = applyEnv(overrides); + ConfigService.fromEnv(); + return snapshot; +}; + +export const resetNotificationsConfig = (snapshot: EnvSnapshot): void => { + ConfigService.clearInstanceForTest(); + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}; From 4cfb3d94e4581cae287499b2e4ac159de4836368 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 20 Feb 2026 15:50:34 +0000 Subject: [PATCH 10/15] feat(notifications): add envoy dev config and e2e --- README.md | 20 +++ docs/runbooks/notifications-gateway.md | 30 +++++ ops/envoy/envoy.dev.local.yaml | 71 ++++++++++ .../e2e/in-memory-redis.mock.ts | 42 ++++++ .../e2e/notifications.gateway.e2e.test.ts | 122 ++++++++++++++++++ packages/notifications-gateway/package.json | 1 + .../notifications-gateway/vitest.config.ts | 2 +- packages/shared/package.json | 3 + pnpm-lock.yaml | 18 ++- 9 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 ops/envoy/envoy.dev.local.yaml create mode 100644 packages/notifications-gateway/e2e/in-memory-redis.mock.ts create mode 100644 packages/notifications-gateway/e2e/notifications.gateway.e2e.test.ts diff --git a/README.md b/README.md index d50142442..93a06b00a 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,26 @@ Secrets handling: - Vault auto-init script under vault/auto-init.sh is dev-only; do not use in production. - Never commit secrets; use environment injection and secure secret managers. +### Dev-local Envoy proxy + +When the platform server (:3010) and notifications gateway (:4000) run directly +on your host, you can still front them with a single origin by mounting the +dev-local Envoy configuration: + +``` +docker run --rm --name envoy-dev \ + -p 8080:8080 \ + -p 9901:9901 \ + -v "$(pwd)/ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro" \ + envoyproxy/envoy:v1.31-latest +``` + +Linux users that prefer docker-compose can add +`extra_hosts: ["host.docker.internal:host-gateway"]` to the Envoy service so the +container can resolve the host network. Point the UI (dev server or production +build) at Envoy with `VITE_API_BASE_URL=http://localhost:8080` to reuse the +single ingress endpoint for both REST and Socket.IO traffic. + ## Observability / Logging / Metrics - Server logging: nestjs-pino with redaction of sensitive headers (packages/platform-server/src/bootstrap/app.module.ts) - Prometheus scrapes Prometheus and cAdvisor; Grafana is pre-provisioned (monitoring/) diff --git a/docs/runbooks/notifications-gateway.md b/docs/runbooks/notifications-gateway.md index 235ef3257..f9ddf60e0 100644 --- a/docs/runbooks/notifications-gateway.md +++ b/docs/runbooks/notifications-gateway.md @@ -53,6 +53,36 @@ The Socket.IO endpoint is exposed on the same origin under `/socket.io`. Any UI or client library that previously connected to the in-process gateway can now point to `http://localhost:8080` and reuse the existing configuration. +## Dev-local Envoy bridge + +When you run the platform server on `:3010` and the notifications gateway on +`:4000` directly on your host, you can still terminate everything behind a +single origin by running Envoy in a standalone container: + +``` +docker run --rm --name envoy-dev \ + -p 8080:8080 \ + -p 9901:9901 \ + -v "$(pwd)/ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro" \ + envoyproxy/envoy:v1.31-latest +``` + +This configuration forwards `/socket.io` upgrades to +`host.docker.internal:4000` (the notifications gateway) with a one-hour idle +timeout and `/api` traffic to `host.docker.internal:3010` (the platform server). + +> [!NOTE] +> On Linux, `host.docker.internal` is not created automatically. If you prefer +> to manage the Envoy sidecar through Compose, add +> `extra_hosts: ["host.docker.internal:host-gateway"]` to the service so the +> container can resolve the host network address. + +Point the UI (Vite dev server or production build) at Envoy via: + +``` +VITE_API_BASE_URL=http://localhost:8080 +``` + ## Shutdown and cleanup Press `Ctrl+C` to stop the stack, then remove containers and volumes with: diff --git a/ops/envoy/envoy.dev.local.yaml b/ops/envoy/envoy.dev.local.yaml new file mode 100644 index 000000000..47c016e12 --- /dev/null +++ b/ops/envoy/envoy.dev.local.yaml @@ -0,0 +1,71 @@ +static_resources: + listeners: + - name: dev_local_ingress + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: dev_local_ingress + use_remote_address: true + upgrade_configs: + - upgrade_type: websocket + route_config: + name: dev_local_route + virtual_hosts: + - name: dev_local_host + domains: + - "*" + routes: + - match: + prefix: "/socket.io" + route: + cluster: notifications_gateway + timeout: 0s + idle_timeout: 3600s + retry_policy: + num_retries: 0 + - match: + prefix: "/api" + route: + cluster: platform_server + timeout: 30s + http_filters: + - name: envoy.filters.http.router + clusters: + - name: platform_server + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: platform_server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: host.docker.internal + port_value: 3010 + - name: notifications_gateway + connect_timeout: 2s + type: STRICT_DNS + lb_policy: ROUND_ROBIN + load_assignment: + cluster_name: notifications_gateway + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: host.docker.internal + port_value: 4000 +admin: + access_log_path: /tmp/admin-access.log + address: + socket_address: + address: 0.0.0.0 + port_value: 9901 diff --git a/packages/notifications-gateway/e2e/in-memory-redis.mock.ts b/packages/notifications-gateway/e2e/in-memory-redis.mock.ts new file mode 100644 index 000000000..d02af68da --- /dev/null +++ b/packages/notifications-gateway/e2e/in-memory-redis.mock.ts @@ -0,0 +1,42 @@ +import { EventEmitter } from 'node:events'; + +const channels: Map> = new Map(); + +class InMemoryRedis extends EventEmitter { + constructor(_url: string, _options?: unknown) { + super(); + } + + async connect(): Promise { + queueMicrotask(() => this.emit('ready')); + return this; + } + + async subscribe(channel: string): Promise { + const subscribers = channels.get(channel) ?? new Set(); + subscribers.add(this); + channels.set(channel, subscribers); + return subscribers.size; + } + + async publish(channel: string, message: string): Promise { + const subscribers = channels.get(channel); + if (!subscribers || subscribers.size === 0) return 0; + for (const subscriber of subscribers) { + queueMicrotask(() => subscriber.emit('message', channel, message)); + } + return subscribers.size; + } + + async quit(): Promise { + for (const [channel, subscribers] of channels.entries()) { + subscribers.delete(this); + if (subscribers.size === 0) { + channels.delete(channel); + } + } + this.removeAllListeners(); + } +} + +export default InMemoryRedis; diff --git a/packages/notifications-gateway/e2e/notifications.gateway.e2e.test.ts b/packages/notifications-gateway/e2e/notifications.gateway.e2e.test.ts new file mode 100644 index 000000000..53acb3600 --- /dev/null +++ b/packages/notifications-gateway/e2e/notifications.gateway.e2e.test.ts @@ -0,0 +1,122 @@ +import { randomUUID } from 'node:crypto'; +import { createServer, type Server as HTTPServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { io as createClient, type Socket } from 'socket.io-client'; +import Redis from 'ioredis'; +import type { NotificationEnvelope } from '@agyn/shared'; +import { describe, expect, test, vi } from 'vitest'; +import { createLogger } from '../src/logger'; +import { createSocketServer } from '../src/socket/server'; +import { NotificationsSubscriber } from '../src/redis/notifications-subscriber'; +import { dispatchToRooms } from '../src/dispatch'; + +vi.mock('ioredis', () => import('./in-memory-redis.mock')); + +const TEST_CHANNEL = 'notifications.v1'; +const TEST_ROOM = 'thread:e2e-room'; +const TEST_EVENT = 'thread.message'; + +type SubscribeAck = { ok: boolean; rooms?: string[]; error?: string }; + +const listen = (server: HTTPServer): Promise => + new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off('error', onError); + reject(error); + }; + server.once('error', onError); + server.listen(0, '127.0.0.1', () => { + server.off('error', onError); + const address = server.address(); + if (!address || typeof address === 'string') { + reject(new Error('failed to determine server port')); + return; + } + resolve((address as AddressInfo).port); + }); + }); + +const closeServer = (server: HTTPServer): Promise => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); + +const waitForConnection = (socket: Socket): Promise => + new Promise((resolve, reject) => { + socket.once('connect', () => resolve()); + socket.once('connect_error', (error) => reject(error)); + }); + +describe('notifications gateway e2e', () => { + test('delivers envelopes published to redis to subscribed clients', async () => { + const httpServer = createServer(); + const logger = createLogger('fatal'); + const io = createSocketServer({ server: httpServer, path: '/socket.io', logger }); + const port = await listen(httpServer); + + const subscriber = new NotificationsSubscriber( + { url: 'redis://in-memory', channel: TEST_CHANNEL }, + logger, + ); + subscriber.on('notification', (envelope: NotificationEnvelope) => dispatchToRooms(io, envelope, logger)); + await subscriber.start(); + + const client = createClient(`http://127.0.0.1:${port}`, { + path: '/socket.io', + transports: ['websocket'], + forceNew: true, + reconnection: false, + }); + + let publisher: Redis | null = null; + try { + await waitForConnection(client); + + const payloadPromise = new Promise((resolve) => { + client.once(TEST_EVENT, (data) => resolve(data)); + }); + + const ack = await new Promise((resolve, reject) => { + const onError = (error: Error) => { + client.off('error', onError); + reject(error); + }; + client.on('error', onError); + client.emit('subscribe', { room: TEST_ROOM }, (response: SubscribeAck) => { + client.off('error', onError); + resolve(response); + }); + }); + + if (!ack.ok) { + throw new Error(`subscribe failed: ${ack.error ?? 'unknown error'}`); + } + expect(ack.rooms).toEqual([TEST_ROOM]); + + const envelope: NotificationEnvelope = { + id: randomUUID(), + ts: new Date().toISOString(), + source: 'platform-server', + rooms: [TEST_ROOM], + event: TEST_EVENT, + payload: { text: 'ping' }, + }; + + publisher = new Redis('redis://in-memory'); + await publisher.connect(); + await publisher.publish(TEST_CHANNEL, JSON.stringify(envelope)); + + const received = await payloadPromise; + expect(received).toEqual(envelope.payload); + } finally { + client.close(); + io.close(); + await subscriber.stop(); + await closeServer(httpServer); + if (publisher) await publisher.quit(); + } + }, 15000); +}); diff --git a/packages/notifications-gateway/package.json b/packages/notifications-gateway/package.json index 274b05aa7..6fa719c14 100644 --- a/packages/notifications-gateway/package.json +++ b/packages/notifications-gateway/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@types/node": "^24.5.1", + "socket.io-client": "^4.8.1", "tsx": "^4.20.5", "typescript": "^5.8.3", "vitest": "3.2.4" diff --git a/packages/notifications-gateway/vitest.config.ts b/packages/notifications-gateway/vitest.config.ts index 752350702..a860d98be 100644 --- a/packages/notifications-gateway/vitest.config.ts +++ b/packages/notifications-gateway/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'e2e/**/*.test.ts'], coverage: { reporter: ['text', 'lcov'], }, diff --git a/packages/shared/package.json b/packages/shared/package.json index ae49a13c2..d6d88540d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,6 +14,9 @@ "build": "tsc -p tsconfig.json", "prepare": "pnpm run build" }, + "devDependencies": { + "typescript": "^5.8.3" + }, "dependencies": { "@langchain/core": "1.0.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 157772750..f873ef6b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@types/node': specifier: ^24.5.1 version: 24.5.2 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 tsx: specifier: ^4.20.5 version: 4.20.5 @@ -742,6 +745,10 @@ importers: '@langchain/core': specifier: 1.0.1 version: 1.0.1(@opentelemetry/api@1.9.0)(@opentelemetry/exporter-trace-otlp-proto@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(openai@6.6.0(ws@8.18.3)(zod@4.1.12)) + devDependencies: + typescript: + specifier: ^5.8.3 + version: 5.8.3 packages: @@ -12350,6 +12357,15 @@ snapshots: msw: 2.11.3(@types/node@20.19.19)(typescript@5.9.2) vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) + '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + msw: 2.11.3(@types/node@24.5.2)(typescript@5.8.3) + vite: 7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1) + '@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -17338,7 +17354,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@24.5.2)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.5.2)(typescript@5.8.3))(vite@7.1.6(@types/node@20.19.19)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.5)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 From 41b9759d2ef340c0a3e25a202b135a2a2942f367 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Fri, 20 Feb 2026 21:41:26 +0000 Subject: [PATCH 11/15] feat(envoy): add dev-local compose service --- README.md | 47 +++++++++++++++++++++----- docker-compose.yml | 17 ++++++++++ docs/runbooks/notifications-gateway.md | 46 +++++++++++++++---------- 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 93a06b00a..8efc0d790 100644 --- a/README.md +++ b/README.md @@ -296,23 +296,52 @@ Secrets handling: ### Dev-local Envoy proxy -When the platform server (:3010) and notifications gateway (:4000) run directly -on your host, you can still front them with a single origin by mounting the -dev-local Envoy configuration: +The default `docker-compose.yml` exposes an `envoy` sidecar that proxies +`/api` → platform server (`:3010`) and `/socket.io` → notifications gateway +(`:4000`) while sharing the same origin. + +1. Start Redis and Envoy: + + ``` + docker compose up -d redis envoy + ``` + +2. Run the platform server and notifications gateway locally. Each process must + publish/consume notifications via Redis: + + ``` + # platform server + NOTIFICATIONS_REDIS_URL=redis://localhost:6379 \ + NOTIFICATIONS_CHANNEL=notifications.v1 \ + pnpm --filter @agyn/platform-server dev + + # notifications gateway + NOTIFICATIONS_REDIS_URL=redis://localhost:6379 \ + NOTIFICATIONS_CHANNEL=notifications.v1 \ + pnpm --filter @agyn/notifications-gateway dev + ``` + +3. Point the UI (Vite dev server or production build) at Envoy: + + ``` + VITE_API_BASE_URL=http://localhost:8080 + ``` + +The Envoy service mounts `ops/envoy/envoy.dev.local.yaml` automatically and +includes `extra_hosts: ["host.docker.internal:host-gateway"]` so Linux hosts can +resolve the loopback address. If you prefer a standalone container, you can run +the same config manually: ``` docker run --rm --name envoy-dev \ -p 8080:8080 \ -p 9901:9901 \ -v "$(pwd)/ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro" \ - envoyproxy/envoy:v1.31-latest + envoyproxy/envoy:v1.30-latest ``` -Linux users that prefer docker-compose can add -`extra_hosts: ["host.docker.internal:host-gateway"]` to the Envoy service so the -container can resolve the host network. Point the UI (dev server or production -build) at Envoy with `VITE_API_BASE_URL=http://localhost:8080` to reuse the -single ingress endpoint for both REST and Socket.IO traffic. +This keeps the browser pointed at `http://localhost:8080` for both REST and +WebSocket traffic. ## Observability / Logging / Metrics - Server logging: nestjs-pino with redaction of sensitive headers (packages/platform-server/src/bootstrap/app.module.ts) diff --git a/docker-compose.yml b/docker-compose.yml index 369bf72a4..3793bb16b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,6 +83,23 @@ services: networks: - agents_net + envoy: + image: envoyproxy/envoy:v1.30-latest + container_name: envoy + restart: unless-stopped + depends_on: + - redis + ports: + - "8080:8080" + - "9901:9901" + volumes: + - ./ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro + command: ["envoy", "-c", "/etc/envoy/envoy.yaml"] + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - agents_net + vault: image: hashicorp/vault:1.17 oom_score_adj: -900 diff --git a/docs/runbooks/notifications-gateway.md b/docs/runbooks/notifications-gateway.md index f9ddf60e0..60cf221cc 100644 --- a/docs/runbooks/notifications-gateway.md +++ b/docs/runbooks/notifications-gateway.md @@ -55,34 +55,46 @@ point to `http://localhost:8080` and reuse the existing configuration. ## Dev-local Envoy bridge -When you run the platform server on `:3010` and the notifications gateway on -`:4000` directly on your host, you can still terminate everything behind a -single origin by running Envoy in a standalone container: +To run the platform server on `:3010` and the notifications gateway on `:4000` +directly on your host while reusing a single origin, bring up Redis and Envoy +from the default compose file: ``` -docker run --rm --name envoy-dev \ - -p 8080:8080 \ - -p 9901:9901 \ - -v "$(pwd)/ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro" \ - envoyproxy/envoy:v1.31-latest +docker compose up -d redis envoy ``` -This configuration forwards `/socket.io` upgrades to -`host.docker.internal:4000` (the notifications gateway) with a one-hour idle -timeout and `/api` traffic to `host.docker.internal:3010` (the platform server). +Then run the application processes locally with Redis wiring: -> [!NOTE] -> On Linux, `host.docker.internal` is not created automatically. If you prefer -> to manage the Envoy sidecar through Compose, add -> `extra_hosts: ["host.docker.internal:host-gateway"]` to the service so the -> container can resolve the host network address. +``` +# platform server +NOTIFICATIONS_REDIS_URL=redis://localhost:6379 \ +NOTIFICATIONS_CHANNEL=notifications.v1 \ +pnpm --filter @agyn/platform-server dev + +# notifications gateway +NOTIFICATIONS_REDIS_URL=redis://localhost:6379 \ +NOTIFICATIONS_CHANNEL=notifications.v1 \ +pnpm --filter @agyn/notifications-gateway dev +``` -Point the UI (Vite dev server or production build) at Envoy via: +Point the UI at Envoy so both REST and Socket.IO traffic share the same origin: ``` VITE_API_BASE_URL=http://localhost:8080 ``` +The compose-managed Envoy mounts `ops/envoy/envoy.dev.local.yaml` and already +includes `extra_hosts: ["host.docker.internal:host-gateway"]` for Linux hosts. +If you prefer to run Envoy manually: + +``` +docker run --rm --name envoy-dev \ + -p 8080:8080 \ + -p 9901:9901 \ + -v "$(pwd)/ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro" \ + envoyproxy/envoy:v1.30-latest +``` + ## Shutdown and cleanup Press `Ctrl+C` to stop the stack, then remove containers and volumes with: From d708480af4063119885a6ed0a07a97e2615a2fbd Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 21 Feb 2026 01:27:56 +0000 Subject: [PATCH 12/15] fix(envoy): ensure compose mount works --- docker-compose.yml | 2 ++ ops/envoy/envoy.dev.local.yaml | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3793bb16b..fd0e3d17c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -92,6 +92,8 @@ services: ports: - "8080:8080" - "9901:9901" + tmpfs: + - /etc/envoy volumes: - ./ops/envoy/envoy.dev.local.yaml:/etc/envoy/envoy.yaml:ro command: ["envoy", "-c", "/etc/envoy/envoy.yaml"] diff --git a/ops/envoy/envoy.dev.local.yaml b/ops/envoy/envoy.dev.local.yaml index 47c016e12..80b22af67 100644 --- a/ops/envoy/envoy.dev.local.yaml +++ b/ops/envoy/envoy.dev.local.yaml @@ -9,7 +9,7 @@ static_resources: - filters: - name: envoy.filters.network.http_connection_manager typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + '@type': "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager" stat_prefix: dev_local_ingress use_remote_address: true upgrade_configs: @@ -25,20 +25,20 @@ static_resources: prefix: "/socket.io" route: cluster: notifications_gateway - timeout: 0s - idle_timeout: 3600s + timeout: "0s" + idle_timeout: "3600s" retry_policy: num_retries: 0 - match: prefix: "/api" route: cluster: platform_server - timeout: 30s + timeout: "30s" http_filters: - name: envoy.filters.http.router clusters: - name: platform_server - connect_timeout: 2s + connect_timeout: "2s" type: STRICT_DNS lb_policy: ROUND_ROBIN load_assignment: @@ -51,7 +51,7 @@ static_resources: address: host.docker.internal port_value: 3010 - name: notifications_gateway - connect_timeout: 2s + connect_timeout: "2s" type: STRICT_DNS lb_policy: ROUND_ROBIN load_assignment: From ee4c7f9c32d3c3ef1d4c858aefb654c9306169ff Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 21 Feb 2026 01:30:40 +0000 Subject: [PATCH 13/15] docs(runbook): capture compose env tips --- README.md | 18 +++++++++++++++++ docs/runbooks/notifications-gateway.md | 27 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/README.md b/README.md index 8efc0d790..66384e137 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,24 @@ WebSocket traffic. - Symptom: UI cannot reach backend in Docker. - Fix: set API_UPSTREAM=http://host.docker.internal:3010 when running UI container locally. +### Docker / Compose setup issues +- **Missing v2 plugin** – `docker compose up -d redis envoy` fails with `docker: 'compose' is not a docker command`. Install the v2 plugin (Docker Desktop or `apt install docker-compose-plugin`) and confirm `docker compose version` reports `v2.29.0` or newer. Envoy relies on `tmpfs` and `host-gateway` features that only exist in Compose v2. +- **Remote daemon bind-mounts** – CI/Codespaces contexts often export `DOCKER_HOST=tcp://localhost:2375`. That remote daemon cannot see files inside this workspace, so bind-mounting `ops/envoy/envoy.dev.local.yaml` turns `/etc/envoy/envoy.yaml` into an empty directory and Envoy exits with `Unable to convert YAML as JSON`. Use a laptop/desktop where the Docker daemon shares the repo filesystem, or copy the config into a Docker volume/image before starting Envoy. +- **Port conflicts** – Envoy uses `8080/9901`, Redis `6379`, notifications gateway `4000`, and LiteLLM `4000` in e2e compose. Stop any other process on those ports before running `docker compose up`. + +### Node / pnpm alignment +- **Node version drift** – The workspace targets Node 22. Install via Nix (`nix profile install nixpkgs#nodejs_22`), Volta, or asdf, then verify with `node -v`. +- **pnpm via Corepack** – Enable Corepack (`corepack enable`) and pin pnpm 10.x (`corepack install pnpm@10.30.1`). Running arbitrary global pnpm versions will mutate the lockfile. +- **Missing pnpm binary** – When Corepack is disabled, `pnpm` is not on `$PATH`. Either enable Corepack or install pnpm globally (`npm i -g pnpm`). +- **File watcher EMFILE errors** – `pnpm --filter @agyn/notifications-gateway dev` can hit the default inotify/file-descriptor limit and fail with `EMFILE: too many open files, watch`. Raise the limit before launching dev servers: + + ``` + ulimit -n 4096 + sudo sysctl fs.inotify.max_user_watches=524288 + ``` + + If raising limits is not possible (e.g., inside constrained CI containers), build once (`pnpm --filter @agyn/notifications-gateway build`) and launch the gateway with `pnpm --filter @agyn/notifications-gateway exec tsx src/index.ts` instead of the watch-mode dev server. + ## Contributing & License - Contributing: see docs/contributing/ and docs/adr/ for architectural decisions. - Code owners: CODEOWNERS file exists at repo root. diff --git a/docs/runbooks/notifications-gateway.md b/docs/runbooks/notifications-gateway.md index 60cf221cc..42732c6d6 100644 --- a/docs/runbooks/notifications-gateway.md +++ b/docs/runbooks/notifications-gateway.md @@ -95,6 +95,33 @@ docker run --rm --name envoy-dev \ envoyproxy/envoy:v1.30-latest ``` +## Troubleshooting & environment notes + +- **Docker Compose v2 required** – The default stack relies on `tmpfs` mounts + and the `host-gateway` extra host entry. Install the Docker Compose v2 plugin + (`docker compose version` should report `v2.29.0` or newer). The legacy + `docker-compose` binary will not work. +- **Remote Docker daemons** – Codespaces/CI setups that export + `DOCKER_HOST=tcp://localhost:2375` cannot bind-mount files from this repo. + In that environment Envoy will log `Unable to convert YAML as JSON` because + `/etc/envoy/envoy.yaml` is replaced by an empty directory. Run the stack on a + machine where the daemon can see the workspace, or bake the config into a + volume/image. +- **pnpm/Node prerequisites** – Use Node 22 (`nix profile install + nixpkgs#nodejs_22`) and enable Corepack so `pnpm@10.x` matches the lockfile + (`corepack enable && corepack install pnpm@10.30.1`). +- **EMFILE watch limits** – When `pnpm --filter @agyn/notifications-gateway + dev` exits with `EMFILE`, raise the limits first: + + ``` + ulimit -n 4096 + sudo sysctl fs.inotify.max_user_watches=524288 + ``` + + Alternatively run the gateway via `pnpm --filter @agyn/notifications-gateway + exec tsx src/index.ts` after a one-time `pnpm --filter + @agyn/notifications-gateway build`. + ## Shutdown and cleanup Press `Ctrl+C` to stop the stack, then remove containers and volumes with: From 4516264c4ee35c3b7fd4dc6439519ef953eb748f Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 21 Feb 2026 11:05:50 +0000 Subject: [PATCH 14/15] ci(e2e): add notifications websocket job --- .github/workflows/e2e-notifications.yml | 82 ++++++++++ .gitignore | 1 + package.json | 4 +- pnpm-lock.yaml | 6 + scripts/e2e/notifications/ws-check.mjs | 194 ++++++++++++++++++++++++ 5 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e-notifications.yml create mode 100644 scripts/e2e/notifications/ws-check.mjs diff --git a/.github/workflows/e2e-notifications.yml b/.github/workflows/e2e-notifications.yml new file mode 100644 index 000000000..693667d3d --- /dev/null +++ b/.github/workflows/e2e-notifications.yml @@ -0,0 +1,82 @@ +name: E2E Notifications + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + workflow_dispatch: + +jobs: + websocket-e2e: + name: Notifications Gateway E2E + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.5.0 + run_install: false + + - name: Get pnpm store directory + id: pnpm-store + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: '20' + + - name: Approve necessary build scripts + run: pnpm approve-builds @prisma/client prisma esbuild @nestjs/core msw + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Start e2e stack + run: docker compose -f docker-compose.e2e.yml up -d --build + + - name: Wait for Envoy readiness + shell: bash + run: | + for attempt in {1..30}; do + if curl -fsS http://localhost:8080 >/dev/null; then + echo "Envoy is ready" + exit 0 + fi + echo "Waiting for Envoy... (${attempt}/30)" + sleep 5 + done + echo "Envoy failed to become ready" >&2 + docker compose -f docker-compose.e2e.yml ps + exit 1 + + - name: Run websocket e2e check + env: + ENVOY_BASE_URL: http://localhost:8080 + SOCKET_IO_PATH: /socket.io + NOTIFICATIONS_REDIS_URL: redis://localhost:6379/0 + NOTIFICATIONS_CHANNEL: notifications.v1 + NOTIFICATIONS_ROOM: thread:test + run: pnpm exec node scripts/e2e/notifications/ws-check.mjs + + - name: Envoy and gateway logs (on failure) + if: failure() + run: docker compose -f docker-compose.e2e.yml logs --no-color envoy notifications-gateway + + - name: Teardown e2e stack + if: always() + run: docker compose -f docker-compose.e2e.yml down -v --remove-orphans diff --git a/.gitignore b/.gitignore index 49e4d87f7..3df9288dc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .pnpm-store .turbo node-compile-cache/ +**/v8-compile-cache-*/ .env packages/platform-server/build-ts # Bundled artifacts generated during build diff --git a/package.json b/package.json index 912f33444..f8aef97c5 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "license": "ISC", "packageManager": "pnpm@10.5.0", "dependencies": { + "ioredis": "^5.4.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", - "socket.io": "^4.8.1" + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "@radix-ui/react-slot": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f873ef6b6..dd9d30f5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + ioredis: + specifier: ^5.4.1 + version: 5.9.3 rehype-raw: specifier: ^7.0.0 version: 7.0.0 @@ -23,6 +26,9 @@ importers: socket.io: specifier: ^4.8.1 version: 4.8.1 + socket.io-client: + specifier: ^4.8.1 + version: 4.8.1 devDependencies: '@radix-ui/react-slot': specifier: ^1.2.3 diff --git a/scripts/e2e/notifications/ws-check.mjs b/scripts/e2e/notifications/ws-check.mjs new file mode 100644 index 000000000..670ca7168 --- /dev/null +++ b/scripts/e2e/notifications/ws-check.mjs @@ -0,0 +1,194 @@ +#!/usr/bin/env node +import process from 'node:process'; +import { randomUUID } from 'node:crypto'; +import Redis from 'ioredis'; +import { io as createSocketClient } from 'socket.io-client'; + +const DEFAULTS = { + envoyBaseUrl: 'http://localhost:8080', + socketPath: '/socket.io', + redisUrl: 'redis://localhost:6379/0', + channel: 'notifications.v1', + room: 'thread:test', + eventName: 'notifications:e2e', + connectTimeoutMs: 15_000, + receiptTimeoutMs: 15_000, +}; + +const loadConfig = (env) => { + const envoyBaseUrl = (env.ENVOY_BASE_URL ?? DEFAULTS.envoyBaseUrl).trim(); + const socketPath = (env.SOCKET_IO_PATH ?? DEFAULTS.socketPath).trim(); + const redisUrl = (env.NOTIFICATIONS_REDIS_URL ?? DEFAULTS.redisUrl).trim(); + const channel = (env.NOTIFICATIONS_CHANNEL ?? DEFAULTS.channel).trim(); + const room = (env.NOTIFICATIONS_ROOM ?? DEFAULTS.room).trim(); + const eventName = (env.NOTIFICATIONS_EVENT ?? DEFAULTS.eventName).trim(); + const connectTimeoutMs = toPositiveInt(env.CONNECT_TIMEOUT_MS) ?? DEFAULTS.connectTimeoutMs; + const receiptTimeoutMs = toPositiveInt(env.RECEIPT_TIMEOUT_MS) ?? DEFAULTS.receiptTimeoutMs; + + assertNonEmpty(envoyBaseUrl, 'ENVOY_BASE_URL'); + assertNonEmpty(socketPath, 'SOCKET_IO_PATH'); + assertNonEmpty(redisUrl, 'NOTIFICATIONS_REDIS_URL'); + assertNonEmpty(channel, 'NOTIFICATIONS_CHANNEL'); + assertNonEmpty(room, 'NOTIFICATIONS_ROOM'); + assertNonEmpty(eventName, 'NOTIFICATIONS_EVENT'); + + return { + envoyBaseUrl, + socketPath, + redisUrl, + channel, + room, + eventName, + connectTimeoutMs, + receiptTimeoutMs, + }; +}; + +const assertNonEmpty = (value, name) => { + if (!value) throw new Error(`${name} is required`); +}; + +const toPositiveInt = (value) => { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const connectSocket = (socket, timeoutMs) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`socket connection timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + const handleConnect = () => { + cleanup(); + resolve(); + }; + + const handleError = (error) => { + cleanup(); + const err = error instanceof Error ? error : new Error(String(error)); + reject(err); + }; + + const cleanup = () => { + clearTimeout(timer); + socket.off('connect', handleConnect); + socket.off('connect_error', handleError); + socket.off('error', handleError); + }; + + socket.once('connect', handleConnect); + socket.once('connect_error', handleError); + socket.once('error', handleError); + }); + +const subscribeToRoom = (socket, room, timeoutMs) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`subscribe ack timeout after ${timeoutMs}ms`)); + }, timeoutMs); + + const handleAck = (response) => { + cleanup(); + if (!response || typeof response !== 'object' || response.ok !== true) { + reject(new Error(`subscribe rejected: ${JSON.stringify(response)}`)); + return; + } + resolve(response.rooms ?? []); + }; + + const cleanup = () => { + clearTimeout(timer); + }; + + socket.emit('subscribe', { room }, handleAck); + }); + +const waitForEvent = (socket, eventName, marker, timeoutMs) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`timed out waiting for ${eventName} within ${timeoutMs}ms`)); + }, timeoutMs); + + const handleEvent = (payload) => { + if (!payload || typeof payload !== 'object' || payload.marker !== marker) { + return; + } + cleanup(); + resolve(payload); + }; + + const cleanup = () => { + clearTimeout(timer); + socket.off(eventName, handleEvent); + }; + + socket.on(eventName, handleEvent); + }); + +const publishEnvelope = async (redis, channel, room, eventName, marker) => { + const envelope = { + id: randomUUID(), + ts: new Date().toISOString(), + source: 'platform-server', + rooms: [room], + event: eventName, + payload: { + marker, + note: 'notifications-e2e', + room, + }, + }; + await redis.publish(channel, JSON.stringify(envelope)); + return envelope; +}; + +const run = async (config) => { + const socket = createSocketClient(config.envoyBaseUrl, { + path: config.socketPath, + transports: ['websocket'], + forceNew: true, + reconnection: false, + timeout: config.connectTimeoutMs, + }); + const redis = new Redis(config.redisUrl, { lazyConnect: true }); + + try { + await Promise.all([connectSocket(socket, config.connectTimeoutMs), redis.connect()]); + console.log('connected to envoy'); + const subscribedRooms = await subscribeToRoom(socket, config.room, config.connectTimeoutMs); + console.log('subscribed to rooms', subscribedRooms); + const marker = randomUUID(); + const receipt = waitForEvent(socket, config.eventName, marker, config.receiptTimeoutMs); + await publishEnvelope(redis, config.channel, config.room, config.eventName, marker); + console.log('published notification, awaiting receipt'); + const payload = await receipt; + console.log('received payload', payload); + } finally { + socket.disconnect(); + try { + await redis.quit(); + } catch { + // ignore redis quit errors + } + } +}; + +const main = async () => { + const config = loadConfig(process.env); + await run(config); +}; + +main() + .then(() => { + console.log('notifications websocket check succeeded'); + process.exit(0); + }) + .catch((error) => { + console.error('notifications websocket check failed', error); + process.exit(1); + }); From 8915fd435530ea8c19f77f1c1c7e271769582652 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Sat, 21 Feb 2026 11:40:16 +0000 Subject: [PATCH 15/15] fix(e2e): skip platform prepare for non-server builds --- .github/workflows/e2e-notifications.yml | 8 ++- docker-compose.e2e.yml | 16 +++-- ops/envoy/envoy.yaml | 2 + packages/docker-runner/Dockerfile | 5 +- packages/docker-runner/package.json | 3 + packages/notifications-gateway/Dockerfile | 5 +- packages/notifications-gateway/package.json | 3 + .../notifications-gateway/src/config.test.ts | 2 +- .../src/dispatch.test.ts | 4 +- .../notifications-gateway/src/dispatch.ts | 4 +- packages/notifications-gateway/src/index.ts | 14 ++--- .../redis/notifications-subscriber.test.ts | 4 +- .../src/redis/notifications-subscriber.ts | 6 +- .../notifications-gateway/src/redis/schema.ts | 2 +- .../src/socket/server.ts | 4 +- .../src/socket/subscriptions.ts | 4 +- packages/platform-server/package.json | 2 +- .../platform-server/scripts/run-prepare.mjs | 23 ++++++++ packages/shared/package.json | 2 +- packages/shared/scripts/run-prepare.mjs | 23 ++++++++ scripts/e2e/notifications/socket-ready.mjs | 58 +++++++++++++++++++ 21 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 packages/platform-server/scripts/run-prepare.mjs create mode 100644 packages/shared/scripts/run-prepare.mjs create mode 100644 scripts/e2e/notifications/socket-ready.mjs diff --git a/.github/workflows/e2e-notifications.yml b/.github/workflows/e2e-notifications.yml index 693667d3d..0d3cb6d60 100644 --- a/.github/workflows/e2e-notifications.yml +++ b/.github/workflows/e2e-notifications.yml @@ -53,7 +53,7 @@ jobs: shell: bash run: | for attempt in {1..30}; do - if curl -fsS http://localhost:8080 >/dev/null; then + if pnpm exec node scripts/e2e/notifications/socket-ready.mjs; then echo "Envoy is ready" exit 0 fi @@ -73,6 +73,12 @@ jobs: NOTIFICATIONS_ROOM: thread:test run: pnpm exec node scripts/e2e/notifications/ws-check.mjs + - name: Capture success proof logs + run: | + docker compose -f docker-compose.e2e.yml logs envoy | tail -n 50 + docker compose -f docker-compose.e2e.yml logs notifications-gateway | tail -n 50 + docker compose -f docker-compose.e2e.yml logs platform-server | tail -n 50 + - name: Envoy and gateway logs (on failure) if: failure() run: docker compose -f docker-compose.e2e.yml logs --no-color envoy notifications-gateway diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 82400f31a..5e15789db 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -45,7 +45,7 @@ services: - agents_net litellm: - image: ghcr.io/berriai/litellm:main-latest + image: ghcr.io/berriai/litellm:v1.80.5-stable restart: unless-stopped environment: DATABASE_URL: postgresql://litellm:change-me@litellm-db:5432/litellm @@ -62,10 +62,14 @@ services: networks: - agents_net healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:4000/health"] - interval: 15s - timeout: 3s - retries: 5 + test: + [ + "CMD-SHELL", + "wget -qO- http://localhost:4000/health || wget -qO- http://localhost:4000/ui || wget -qO- http://localhost:4000/", + ] + interval: 10s + timeout: 5s + retries: 12 start_period: 10s docker-runner: @@ -93,7 +97,7 @@ services: redis: condition: service_started litellm: - condition: service_healthy + condition: service_started docker-runner: condition: service_started environment: diff --git a/ops/envoy/envoy.yaml b/ops/envoy/envoy.yaml index 71873f4f5..63cb28bbd 100644 --- a/ops/envoy/envoy.yaml +++ b/ops/envoy/envoy.yaml @@ -37,6 +37,8 @@ static_resources: cluster: platform_server http_filters: - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: platform_server connect_timeout: 2s diff --git a/packages/docker-runner/Dockerfile b/packages/docker-runner/Dockerfile index 92c0d392d..7202b23e2 100644 --- a/packages/docker-runner/Dockerfile +++ b/packages/docker-runner/Dockerfile @@ -23,11 +23,12 @@ FROM base AS build COPY . . -RUN pnpm install --filter @agyn/docker-runner... --offline --frozen-lockfile +RUN SKIP_PLATFORM_SERVER_PREPARE=1 SKIP_SHARED_PREPARE=1 pnpm install --filter @agyn/docker-runner... --offline --frozen-lockfile RUN pnpm --filter @agyn/docker-runner run build -RUN pnpm deploy --filter @agyn/docker-runner --prod --legacy /opt/app +RUN install -d /opt/app/packages/docker-runner \ + && SKIP_PLATFORM_SERVER_PREPARE=1 SKIP_SHARED_PREPARE=1 pnpm deploy --filter @agyn/docker-runner --prod --legacy /opt/app/packages/docker-runner FROM node:20-slim AS runtime diff --git a/packages/docker-runner/package.json b/packages/docker-runner/package.json index 941443a00..75b8d4508 100644 --- a/packages/docker-runner/package.json +++ b/packages/docker-runner/package.json @@ -11,6 +11,9 @@ "lint": "eslint .", "test": "vitest run" }, + "files": [ + "dist" + ], "dependencies": { "@fastify/websocket": "^11.2.0", "@nestjs/common": "^11.1.7", diff --git a/packages/notifications-gateway/Dockerfile b/packages/notifications-gateway/Dockerfile index 9d25349ad..656d0cd7b 100644 --- a/packages/notifications-gateway/Dockerfile +++ b/packages/notifications-gateway/Dockerfile @@ -23,11 +23,12 @@ FROM base AS build COPY . . -RUN pnpm install --filter @agyn/notifications-gateway... --offline --frozen-lockfile +RUN SKIP_PLATFORM_SERVER_PREPARE=1 pnpm install --filter @agyn/notifications-gateway... --offline --frozen-lockfile RUN pnpm --filter @agyn/notifications-gateway run build -RUN pnpm deploy --filter @agyn/notifications-gateway --prod --legacy /opt/app +RUN install -d /opt/app/packages/notifications-gateway \ + && SKIP_PLATFORM_SERVER_PREPARE=1 pnpm deploy --filter @agyn/notifications-gateway --prod --legacy /opt/app/packages/notifications-gateway FROM node:20-slim AS runtime diff --git a/packages/notifications-gateway/package.json b/packages/notifications-gateway/package.json index 6fa719c14..c326a6e5b 100644 --- a/packages/notifications-gateway/package.json +++ b/packages/notifications-gateway/package.json @@ -10,6 +10,9 @@ "start": "node dist/index.js", "test": "vitest run" }, + "files": [ + "dist" + ], "dependencies": { "@agyn/shared": "workspace:*", "dotenv": "17.2.2", diff --git a/packages/notifications-gateway/src/config.test.ts b/packages/notifications-gateway/src/config.test.ts index 7af2e97de..2ab0ca806 100644 --- a/packages/notifications-gateway/src/config.test.ts +++ b/packages/notifications-gateway/src/config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { loadConfig } from './config'; +import { loadConfig } from './config.js'; const TRACKED_KEYS = ['PORT', 'HOST', 'SOCKET_IO_PATH', 'NOTIFICATIONS_REDIS_URL', 'NOTIFICATIONS_CHANNEL', 'LOG_LEVEL'] as const; const snapshots: Record = {}; diff --git a/packages/notifications-gateway/src/dispatch.test.ts b/packages/notifications-gateway/src/dispatch.test.ts index d42a59b88..16df840ee 100644 --- a/packages/notifications-gateway/src/dispatch.test.ts +++ b/packages/notifications-gateway/src/dispatch.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { NotificationEnvelope } from '@agyn/shared'; -import type { Logger } from './logger'; +import type { Logger } from './logger.js'; import type { Server as SocketIOServer } from 'socket.io'; -import { dispatchToRooms } from './dispatch'; +import { dispatchToRooms } from './dispatch.js'; const createLogger = (): Logger => ({ diff --git a/packages/notifications-gateway/src/dispatch.ts b/packages/notifications-gateway/src/dispatch.ts index 0e9a1fa2c..1406f5cbb 100644 --- a/packages/notifications-gateway/src/dispatch.ts +++ b/packages/notifications-gateway/src/dispatch.ts @@ -1,7 +1,7 @@ import type { NotificationEnvelope } from '@agyn/shared'; import type { Server as SocketIOServer } from 'socket.io'; -import type { Logger } from './logger'; -import { serializeError } from './errors'; +import type { Logger } from './logger.js'; +import { serializeError } from './errors.js'; export const dispatchToRooms = ( io: SocketIOServer, diff --git a/packages/notifications-gateway/src/index.ts b/packages/notifications-gateway/src/index.ts index e04d18ce0..e28bd1ea8 100644 --- a/packages/notifications-gateway/src/index.ts +++ b/packages/notifications-gateway/src/index.ts @@ -1,13 +1,13 @@ import { createServer } from 'node:http'; import process from 'node:process'; -import { loadConfig } from './config'; -import { createLogger } from './logger'; -import { createSocketServer } from './socket/server'; -import { NotificationsSubscriber } from './redis/notifications-subscriber'; -import { dispatchToRooms } from './dispatch'; -import { serializeError } from './errors'; +import { loadConfig } from './config.js'; +import { createLogger } from './logger.js'; +import { createSocketServer } from './socket/server.js'; +import { NotificationsSubscriber } from './redis/notifications-subscriber.js'; +import { dispatchToRooms } from './dispatch.js'; +import { serializeError } from './errors.js'; import type { NotificationEnvelope } from '@agyn/shared'; -import type { Logger } from './logger'; +import type { Logger } from './logger.js'; import type { Server as SocketIOServer } from 'socket.io'; async function main(): Promise { diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts index d09af1e7f..1a74a4aea 100644 --- a/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.test.ts @@ -1,7 +1,7 @@ import { EventEmitter } from 'node:events'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { NotificationsSubscriber } from './notifications-subscriber'; -import type { Logger } from '../logger'; +import { NotificationsSubscriber } from './notifications-subscriber.js'; +import type { Logger } from '../logger.js'; class RedisStub extends EventEmitter { connect = vi.fn(async () => {}); diff --git a/packages/notifications-gateway/src/redis/notifications-subscriber.ts b/packages/notifications-gateway/src/redis/notifications-subscriber.ts index 3f4d8d232..6fcdd953b 100644 --- a/packages/notifications-gateway/src/redis/notifications-subscriber.ts +++ b/packages/notifications-gateway/src/redis/notifications-subscriber.ts @@ -1,9 +1,9 @@ import { EventEmitter } from 'node:events'; import Redis from 'ioredis'; -import type { Logger } from '../logger'; +import type { Logger } from '../logger.js'; import type { NotificationEnvelope } from '@agyn/shared'; -import { NotificationEnvelopeSchema } from './schema'; -import { serializeError } from '../errors'; +import { NotificationEnvelopeSchema } from './schema.js'; +import { serializeError } from '../errors.js'; export class NotificationsSubscriber extends EventEmitter { private redis: Redis | null = null; diff --git a/packages/notifications-gateway/src/redis/schema.ts b/packages/notifications-gateway/src/redis/schema.ts index 205042da7..fafd67e21 100644 --- a/packages/notifications-gateway/src/redis/schema.ts +++ b/packages/notifications-gateway/src/redis/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import type { NotificationEnvelope } from '@agyn/shared'; -import { RoomSchema } from '../rooms'; +import { RoomSchema } from '../rooms.js'; export const NotificationEnvelopeSchema: z.ZodType = z .object({ diff --git a/packages/notifications-gateway/src/socket/server.ts b/packages/notifications-gateway/src/socket/server.ts index a3573eb23..6d6cb5ca0 100644 --- a/packages/notifications-gateway/src/socket/server.ts +++ b/packages/notifications-gateway/src/socket/server.ts @@ -1,7 +1,7 @@ import type { Server as HTTPServer } from 'node:http'; import { Server as SocketIOServer, type ServerOptions } from 'socket.io'; -import type { Logger } from '../logger'; -import { attachSubscribeHandler } from './subscriptions'; +import type { Logger } from '../logger.js'; +import { attachSubscribeHandler } from './subscriptions.js'; export const createSocketServer = (params: { server: HTTPServer; diff --git a/packages/notifications-gateway/src/socket/subscriptions.ts b/packages/notifications-gateway/src/socket/subscriptions.ts index e29633dc8..6a95b6b2c 100644 --- a/packages/notifications-gateway/src/socket/subscriptions.ts +++ b/packages/notifications-gateway/src/socket/subscriptions.ts @@ -1,7 +1,7 @@ import type { Socket } from 'socket.io'; import { z } from 'zod'; -import type { Logger } from '../logger'; -import { RoomSchema, type ValidRoom } from '../rooms'; +import type { Logger } from '../logger.js'; +import { RoomSchema, type ValidRoom } from '../rooms.js'; const SubscribeSchema = z .object({ diff --git a/packages/platform-server/package.json b/packages/platform-server/package.json index 24a4ec969..097f86b03 100644 --- a/packages/platform-server/package.json +++ b/packages/platform-server/package.json @@ -15,7 +15,7 @@ "test:litellm": "vitest run __tests__/integration/litellm.integration.test.ts", "test:watch": "vitest", "lint": "pnpm run prisma:generate && eslint .", - "prepare": "prisma generate", + "prepare": "node ./scripts/run-prepare.mjs", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio" diff --git a/packages/platform-server/scripts/run-prepare.mjs b/packages/platform-server/scripts/run-prepare.mjs new file mode 100644 index 000000000..6badb92f4 --- /dev/null +++ b/packages/platform-server/scripts/run-prepare.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; + +const raw = process.env.SKIP_PLATFORM_SERVER_PREPARE; +const shouldSkip = typeof raw === 'string' && ['1', 'true', 'yes'].includes(raw.toLowerCase()); + +if (shouldSkip) { + console.log('Skipping @agyn/platform-server prepare script.'); + process.exit(0); +} + +const result = spawnSync('prisma', ['generate'], { + stdio: 'inherit', + env: process.env, +}); + +if (result.error) { + console.error('Failed to run prisma generate:', result.error.message); + process.exit(result.status ?? 1); +} + +process.exit(result.status ?? 0); diff --git a/packages/shared/package.json b/packages/shared/package.json index d6d88540d..e2768e0f1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,7 +12,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json", - "prepare": "pnpm run build" + "prepare": "node ./scripts/run-prepare.mjs" }, "devDependencies": { "typescript": "^5.8.3" diff --git a/packages/shared/scripts/run-prepare.mjs b/packages/shared/scripts/run-prepare.mjs new file mode 100644 index 000000000..8bcdb987e --- /dev/null +++ b/packages/shared/scripts/run-prepare.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; + +const raw = process.env.SKIP_SHARED_PREPARE; +const shouldSkip = typeof raw === 'string' && ['1', 'true', 'yes'].includes(raw.toLowerCase()); + +if (shouldSkip) { + console.log('Skipping @agyn/shared prepare script.'); + process.exit(0); +} + +const result = spawnSync('pnpm', ['run', 'build'], { + stdio: 'inherit', + env: process.env, +}); + +if (result.error) { + console.error('Failed to run shared build:', result.error.message); + process.exit(result.status ?? 1); +} + +process.exit(result.status ?? 0); diff --git a/scripts/e2e/notifications/socket-ready.mjs b/scripts/e2e/notifications/socket-ready.mjs new file mode 100644 index 000000000..6c9b4e7e9 --- /dev/null +++ b/scripts/e2e/notifications/socket-ready.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +import process from 'node:process'; +import { io as createSocketClient } from 'socket.io-client'; + +const DEFAULTS = { + envoyBaseUrl: 'http://localhost:8080', + socketPath: '/socket.io', + timeoutMs: 5000, +}; + +const toPositiveInt = (value) => { + if (!value) return null; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const main = async () => { + const envoyBaseUrl = (process.env.ENVOY_BASE_URL ?? DEFAULTS.envoyBaseUrl).trim(); + const socketPath = (process.env.SOCKET_IO_PATH ?? DEFAULTS.socketPath).trim(); + const timeoutMs = toPositiveInt(process.env.CONNECT_TIMEOUT_MS) ?? DEFAULTS.timeoutMs; + + if (!envoyBaseUrl) throw new Error('ENVOY_BASE_URL is required'); + if (!socketPath) throw new Error('SOCKET_IO_PATH is required'); + + const socket = createSocketClient(envoyBaseUrl, { + path: socketPath, + transports: ['websocket'], + reconnection: false, + forceNew: true, + timeout: timeoutMs, + }); + + try { + await new Promise((resolve, reject) => { + const handleError = (error) => { + socket.off('connect', handleConnect); + reject(error); + }; + + const handleConnect = () => { + socket.off('connect_error', handleError); + socket.off('error', handleError); + resolve(); + }; + + socket.once('connect', handleConnect); + socket.once('connect_error', handleError); + socket.once('error', handleError); + }); + } finally { + socket.disconnect(); + } +}; + +main().catch((error) => { + console.error('socket readiness check failed', error); + process.exit(1); +});