feat(ai-core): Introduce AI agent actions, platform API client, and health monitoring for bookings-tryst feature

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-03 23:06:59 -07:00
parent 0b3d62876e
commit 54b547201c
5 changed files with 190 additions and 0 deletions

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"watchAssets": true,
"assets": ["**/*.json"]
}
}

View file

@ -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<AgentActionRef> {
return this.platformApi.post<AgentActionRef>('/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,
});
}
}

View file

@ -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<string>('PLATFORM_API_BASE_URL');
this.token = config.getOrThrow<string>('SPECIALIST_TOKEN');
this.timeoutMs = config.get<number>('PLATFORM_API_TIMEOUT_MS', 5000);
}
async get<T>(path: string, query?: Record<string, string | number | boolean>): Promise<T> {
try {
const response = await firstValueFrom(
this.http.get<T>(`${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<T>(path: string, body: unknown): Promise<T> {
try {
const response = await firstValueFrom(
this.http.post<T>(`${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}`);
}
}
}

View file

@ -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 {}

View file

@ -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<void> {
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<string>('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<number>('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);
});