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:
parent
0b3d62876e
commit
54b547201c
5 changed files with 190 additions and 0 deletions
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"watchAssets": true,
|
||||
"assets": ["**/*.json"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue