From 54b547201c1464dd1fc8d4c2c05ece13d9893b3f Mon Sep 17 00:00:00 2001 From: autocommit Date: Wed, 3 Jun 2026 23:06:59 -0700 Subject: [PATCH] =?UTF-8?q?feat(ai-core):=20=E2=9C=A8=20Introduce=20AI=20a?= =?UTF-8?q?gent=20actions,=20platform=20API=20client,=20and=20health=20mon?= =?UTF-8?q?itoring=20for=20bookings-tryst=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../bookings-tryst/ai-core/nest-cli.json | 10 ++++ .../src/context/agent-actions.client.ts | 37 ++++++++++++ .../src/context/platform-api.client.ts | 59 +++++++++++++++++++ .../ai-core/src/health/health.module.ts | 35 +++++++++++ .../bookings-tryst/ai-core/src/main.ts | 49 +++++++++++++++ 5 files changed, 190 insertions(+) create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/nest-cli.json create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/context/agent-actions.client.ts create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/context/platform-api.client.ts create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/health/health.module.ts create mode 100644 @platform/codebase/@features/bookings-tryst/ai-core/src/main.ts diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/nest-cli.json b/@platform/codebase/@features/bookings-tryst/ai-core/nest-cli.json new file mode 100644 index 0000000..6026409 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "watchAssets": true, + "assets": ["**/*.json"] + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/context/agent-actions.client.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/agent-actions.client.ts new file mode 100644 index 0000000..77ccb68 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/agent-actions.client.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import type { + AgentActionRef, + AgentActionsClient as AgentActionsClientContract, + AgentActionWrite, +} from '@cocottetech/surface-adapter-contracts'; + +import { PlatformApiClient } from './platform-api.client.js'; + +/** + * Writes `agent_actions` audit rows through platform.api (it owns the only + * connection to platform.db). Implements the contract's {@link AgentActionsClientContract} + * so adapter actions stay decoupled from the data plane. + * + * Wire shape mirrors the `agent_actions` columns + * (`infrastructure/sql/migrations/0001_tenancy_and_content.sql`). The endpoint is + * `POST /agent-actions` (platform.api owns persistence + append-only enforcement). + */ +@Injectable() +export class AgentActionsClient implements AgentActionsClientContract { + constructor(private readonly platformApi: PlatformApiClient) {} + + async record(write: AgentActionWrite): Promise { + return this.platformApi.post('/agent-actions', { + user_id: write.userId, + org_id: write.orgId ?? null, + specialist_id: write.specialistId, + action_type: write.actionType, + target_kind: write.targetKind, + target_id: write.targetId ?? null, + stakes: write.stakes, + confidence: write.confidence, + auto_executed: write.autoExecuted, + outcome_json: write.outcome ?? null, + }); + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/context/platform-api.client.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/platform-api.client.ts new file mode 100644 index 0000000..84a2ad3 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/platform-api.client.ts @@ -0,0 +1,59 @@ +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { PlatformApiClient as PlatformApiClientContract } from '@cocottetech/surface-adapter-contracts'; +import { firstValueFrom } from 'rxjs'; + +/** + * Single HTTP client wrapping calls to platform.api. Implements the + * `PlatformApiClient` contract from `@cocottetech/surface-adapter-contracts`, so + * the adapter actions depend on the abstraction (DIP), not on raw axios — retry / + * timeout / auth policy lives in ONE place (DRY). + */ +@Injectable() +export class PlatformApiClient implements PlatformApiClientContract { + private readonly logger = new Logger(PlatformApiClient.name); + private readonly baseUrl: string; + private readonly token: string; + private readonly timeoutMs: number; + + constructor( + private readonly http: HttpService, + config: ConfigService, + ) { + this.baseUrl = config.getOrThrow('PLATFORM_API_BASE_URL'); + this.token = config.getOrThrow('SPECIALIST_TOKEN'); + this.timeoutMs = config.get('PLATFORM_API_TIMEOUT_MS', 5000); + } + + async get(path: string, query?: Record): Promise { + try { + const response = await firstValueFrom( + this.http.get(`${this.baseUrl}${path}`, { + params: query, + timeout: this.timeoutMs, + headers: { Authorization: `Bearer ${this.token}` }, + }), + ); + return response.data; + } catch (err: unknown) { + this.logger.error(`GET ${path} failed`, err instanceof Error ? err.stack : String(err)); + throw new ServiceUnavailableException(`platform.api unreachable: GET ${path}`); + } + } + + async post(path: string, body: unknown): Promise { + try { + const response = await firstValueFrom( + this.http.post(`${this.baseUrl}${path}`, body, { + timeout: this.timeoutMs, + headers: { Authorization: `Bearer ${this.token}` }, + }), + ); + return response.data; + } catch (err: unknown) { + this.logger.error(`POST ${path} failed`, err instanceof Error ? err.stack : String(err)); + throw new ServiceUnavailableException(`platform.api unreachable: POST ${path}`); + } + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/health/health.module.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/health/health.module.ts new file mode 100644 index 0000000..be40408 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/health/health.module.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Module } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { AdapterRegistryService } from '../adapter/adapter-registry.service.js'; + +interface HealthResponse { + status: 'ok'; + service: 'bookings-tryst'; + surface: string; + /** (surface, verb) keys currently registered — proves the registry wired up. */ + actions: readonly string[]; + ts: string; +} + +@ApiTags('health') +@Controller('health') +class HealthController { + constructor(private readonly registry: AdapterRegistryService) {} + + @Get() + check(): HealthResponse { + return { + status: 'ok', + service: 'bookings-tryst', + surface: this.registry.surface, + actions: this.registry.registeredKeys(), + ts: new Date().toISOString(), + }; + } +} + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/main.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/main.ts new file mode 100644 index 0000000..f036940 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/main.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; + +import { Logger, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +import { AppModule } from './app.module.js'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule, { bufferLogs: true }); + const config = app.get(ConfigService); + const logger = new Logger('Bootstrap'); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: false }, + }), + ); + + app.setGlobalPrefix('api/v1', { exclude: ['health', 'health/(.*)'] }); + + if (config.get('NODE_ENV') !== 'production') { + const swaggerConfig = new DocumentBuilder() + .setTitle('bookings-tryst') + .setDescription('V4 Tryst escort-directory specialist @ai instance — runs on black') + .setVersion('0.1.0') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('docs', app, document); + } + + const port = config.get('BOOKINGS_TRYST_PORT', 3796); + await app.listen(port, '0.0.0.0'); + + logger.log( + `bookings-tryst listening on :${port} (env=${config.get('NODE_ENV') ?? 'development'})`, + ); +} + +bootstrap().catch((err: unknown) => { + const logger = new Logger('Bootstrap'); + logger.error('Fatal bootstrap error', err instanceof Error ? err.stack : String(err)); + process.exit(1); +});