diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/README.md b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/README.md new file mode 100644 index 0000000..f843f1e --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/README.md @@ -0,0 +1,73 @@ +# `adapter/` — surface action folders (data-driven registry) + +Each action is **one folder** here: `adapter//index.ts`. `AdapterRegistryService` +scans this directory at bootstrap, dynamic-imports each `index.ts`, and registers its +`descriptor`. **You do not edit any shared import list, module, or barrel** — adding a +folder is adding an action. + +`` is one of the `ActionVerb`s from `@cocottetech/surface-adapter-contracts`: +`bump`, `update-profile`, `reply`, `fetch-inbox`, `fetch-metrics`, `tour-announce`, `home-city`. + +## What `adapter//index.ts` MUST export + +Exactly one named const `descriptor` of type `ActionDescriptor`: + +```ts +import { z } from 'zod'; +import { + type ActionDescriptor, + type SurfaceAdapterAction, + precheckResult, + gateK1ProspectBlocklist, +} from '@cocottetech/surface-adapter-contracts'; + +const schema = z.object({ + prospectId: z.string(), + body: z.string().min(1), +}); +type Input = z.infer; +interface Output { + externalId: string; +} + +const action: SurfaceAdapterAction = { + action: 'reply', + schema, + async precheck(input, ctx) { + const blocked = await ctx.blocklist.list('prospect'); + return precheckResult(gateK1ProspectBlocklist(input.prospectId, blocked)); + }, + async execute(input, ctx) { + // ... drive ctx.session, then: + const ref = await ctx.agentActions.record({ + userId: ctx.userId, + orgId: ctx.orgId, + specialistId: 'bookings-tryst', + actionType: 'reply', + targetKind: 'prospect', + stakes: 'medium', + confidence: 0.9, + autoExecuted: false, + outcome: { external_id: 'msg_123' }, + }); + return { externalId: ref.id }; + }, +}; + +export const descriptor: ActionDescriptor = { + surface: 'tryst', + verb: 'reply', + action: action as SurfaceAdapterAction, + autoExecutable: false, +}; +``` + +## Rules + +- `descriptor.surface` MUST equal this specialist's surface (`tryst`); mismatches are skipped. +- `(surface, verb)` must be unique — a duplicate throws `DuplicateActionError` at boot. +- `precheck` must be deterministic and call the K-gate functions; if it returns `!ok`, + the dispatcher never calls `execute` (no container spin-up). +- `execute` writes the `agent_actions` audit row via `ctx.agentActions.record(...)`. +- Never import `playwright` into the contract surface — drive the browser through + `ctx.session` (the opaque, container-managed handle). diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter-registry.service.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter-registry.service.ts new file mode 100644 index 0000000..f48c0bd --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter-registry.service.ts @@ -0,0 +1,147 @@ +import { existsSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + type ActionDescriptor, + type ActionVerb, + ActionRegistry, + type SurfaceKind, + isActionModule, + isSurfaceKind, +} from '@cocottetech/surface-adapter-contracts'; + +/** + * Data-driven collection point. At bootstrap it scans the sibling action + * directories (`adapter//index.ts`), dynamic-`import()`s each, validates + * the module exports a `descriptor`, and registers it. This is the mechanism that + * lets the five parallel agents add actions WITHOUT editing any shared import + * block: a new action is a new folder, nothing else. + * + * Booting with ZERO action folders is valid — the registry is simply empty and + * the service stays healthy (the health endpoint reports `actions: []`). The + * scan never throws on an absent/empty directory. + * + * Contract a new action file MUST satisfy — `adapter//index.ts`: + * + * import { z } from 'zod'; + * import type { ActionDescriptor, SurfaceAdapterAction } from '@cocottetech/surface-adapter-contracts'; + * + * const schema = z.object({ ... }); + * type Input = z.infer; + * interface Output { ... } + * + * const action: SurfaceAdapterAction = { + * action: 'bump', + * schema, + * async precheck(input, ctx) { ... }, + * async execute(input, ctx) { ... }, + * }; + * + * export const descriptor: ActionDescriptor = { + * surface: 'tryst', + * verb: 'bump', + * action: action as SurfaceAdapterAction, + * autoExecutable: false, + * }; + */ +@Injectable() +export class AdapterRegistryService implements OnModuleInit { + private readonly logger = new Logger(AdapterRegistryService.name); + private readonly registry = new ActionRegistry(); + readonly surface: SurfaceKind; + + constructor(config: ConfigService) { + const configured = config.get('SURFACE_KIND', 'tryst'); + if (!isSurfaceKind(configured)) { + throw new Error(`SURFACE_KIND="${configured}" is not a known SurfaceKind`); + } + this.surface = configured; + } + + async onModuleInit(): Promise { + await this.discover(); + this.logger.log( + `adapter registry: ${this.registry.size} action(s) for surface=${this.surface} ` + + `[${this.registry.keys().join(', ')}]`, + ); + } + + /** Resolve a registered action descriptor for this surface + verb. */ + resolve(verb: ActionVerb): ActionDescriptor | undefined { + return this.registry.resolve(this.surface, verb); + } + + /** Every registered descriptor. */ + all(): readonly ActionDescriptor[] { + return this.registry.all(); + } + + /** (surface, verb) keys currently registered — surfaced on the health endpoint. */ + registeredKeys(): readonly string[] { + return this.registry.keys(); + } + + /** + * Scan `adapter//index.{js,ts}`, import each, register its `descriptor`. + * Resolves against the compiled `dist` layout (where `import.meta.url` points at + * runtime); falls back to no-op when the directory is absent. + */ + private async discover(): Promise { + const here = dirname(fileURLToPath(import.meta.url)); + if (!existsSync(here)) { + return; + } + const subdirs = readdirSync(here, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + for (const dir of subdirs) { + const indexPath = this.resolveIndex(here, dir); + if (!indexPath) { + continue; + } + await this.loadDescriptor(indexPath, dir); + } + } + + /** Find the action entrypoint inside an action subdir; null if none present. */ + private resolveIndex(base: string, dir: string): string | null { + for (const candidate of ['index.js', 'index.mjs', 'index.ts']) { + const full = join(base, dir, candidate); + if (existsSync(full)) { + return full; + } + } + return null; + } + + private async loadDescriptor(indexPath: string, dir: string): Promise { + let mod: unknown; + try { + mod = (await import(pathToFileURL(indexPath).href)) as unknown; + } catch (err: unknown) { + this.logger.error( + `failed to import adapter action "${dir}"`, + err instanceof Error ? err.stack : String(err), + ); + return; + } + if (!isActionModule(mod)) { + this.logger.warn( + `adapter dir "${dir}" has no valid \`descriptor\` export — skipping`, + ); + return; + } + if (mod.descriptor.surface !== this.surface) { + this.logger.warn( + `adapter "${dir}" declares surface=${mod.descriptor.surface} but this specialist ` + + `serves ${this.surface} — skipping`, + ); + return; + } + this.registry.register(mod.descriptor); + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter.module.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter.module.ts new file mode 100644 index 0000000..90572d2 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/adapter/adapter.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; + +import { AdapterRegistryService } from './adapter-registry.service.js'; + +/** + * Houses the data-driven action registry. Global + exported so the dispatcher and + * the health endpoint can inject {@link AdapterRegistryService} without each action + * touching a shared providers list. Parallel agents add actions by dropping an + * `adapter//index.ts` folder — they do NOT edit this module. + */ +@Global() +@Module({ + providers: [AdapterRegistryService], + exports: [AdapterRegistryService], +}) +export class AdapterModule {} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/context/adapter-context.factory.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/adapter-context.factory.ts new file mode 100644 index 0000000..1d8a8aa --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/adapter-context.factory.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { + AdapterContext, + AdapterLogger, + SurfaceSession, +} from '@cocottetech/surface-adapter-contracts'; + +import { AgentActionsClient } from './agent-actions.client.js'; +import { BlocklistAccessorFactory } from './blocklist.accessor.js'; +import { PlatformApiClient } from './platform-api.client.js'; + +/** Identity + live session a dispatch supplies to build its {@link AdapterContext}. */ +export interface AdapterInvocation { + userId: string; + orgId?: string; + /** The container-managed authenticated surface session (opaque to the contract). */ + session: SurfaceSession; +} + +/** + * Builds a fresh {@link AdapterContext} per dispatched action, wiring the + * tenant-scoped blocklist accessor, the shared platform.api client, the audit + * writer, and a structured logger. This is the single construction point for the + * context every parallel agent's action receives — agents never build it. + */ +@Injectable() +export class AdapterContextFactory { + constructor( + private readonly platformApi: PlatformApiClient, + private readonly blocklistFactory: BlocklistAccessorFactory, + private readonly agentActions: AgentActionsClient, + ) {} + + build(invocation: AdapterInvocation): AdapterContext { + const { userId, orgId, session } = invocation; + const logger = this.adapterLogger(userId, session.surface); + + const context: AdapterContext = { + userId, + ...(orgId !== undefined ? { orgId } : {}), + session, + platformApi: this.platformApi, + blocklist: this.blocklistFactory.forTenant(userId, orgId), + agentActions: this.agentActions, + logger, + }; + return context; + } + + /** A contract {@link AdapterLogger} backed by NestJS's Logger, surface-tagged. */ + private adapterLogger(userId: string, surface: string): AdapterLogger { + const nest = new Logger(`adapter:${surface}`); + const tag = (msg: string): string => `[user=${userId}] ${msg}`; + return { + log: (msg) => nest.log(tag(msg)), + warn: (msg) => nest.warn(tag(msg)), + error: (msg, trace) => nest.error(tag(msg), trace), + debug: (msg) => nest.debug(tag(msg)), + }; + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/context/blocklist.accessor.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/blocklist.accessor.ts new file mode 100644 index 0000000..a7a1c9b --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/blocklist.accessor.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import type { + BlocklistAccessor as BlocklistAccessorContract, + BlocklistEntry, + BlocklistKind, +} from '@cocottetech/surface-adapter-contracts'; + +import { PlatformApiClient } from './platform-api.client.js'; + +/** Wire shape of one blocklist row as platform.api returns it (snake_case). */ +interface BlocklistEntryWire { + id: string; + user_id: string; + org_id: string | null; + kind: BlocklistKind; + value: string; + scope: 'global' | string[]; + reason: string | null; + expires_at: string | null; + created_at: string; + created_by: 'user' | 'auto'; +} + +/** + * Reads the tenant's safety blocklist from platform.api + * (`GET /safety/blocklist`, brief K §Inputs) and maps it to the contract's + * {@link BlocklistEntry}. A per-tenant accessor is constructed per invocation by + * the {@link AdapterContextFactory}; this class is the surface that does the read. + */ +@Injectable() +export class BlocklistAccessorFactory { + constructor(private readonly platformApi: PlatformApiClient) {} + + /** Build a {@link BlocklistAccessorContract} scoped to one tenant. */ + forTenant(userId: string, orgId?: string): BlocklistAccessorContract { + const platformApi = this.platformApi; + return { + async list(kind?: BlocklistKind): Promise { + const query: Record = { user_id: userId }; + if (orgId) { + query['org_id'] = orgId; + } + if (kind) { + query['kind'] = kind; + } + const rows = await platformApi.get('/safety/blocklist', query); + return rows.map(mapEntry); + }, + }; + } +} + +function mapEntry(row: BlocklistEntryWire): BlocklistEntry { + return { + id: row.id, + userId: row.user_id, + orgId: row.org_id, + kind: row.kind, + value: row.value, + scope: row.scope === 'global' ? 'global' : (row.scope as BlocklistEntry['scope']), + reason: row.reason, + expiresAt: row.expires_at, + createdAt: row.created_at, + createdBy: row.created_by, + }; +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/context/context.module.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/context.module.ts new file mode 100644 index 0000000..6800975 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/context/context.module.ts @@ -0,0 +1,25 @@ +import { HttpModule } from '@nestjs/axios'; +import { Global, Module } from '@nestjs/common'; + +import { AdapterContextFactory } from './adapter-context.factory.js'; +import { AgentActionsClient } from './agent-actions.client.js'; +import { BlocklistAccessorFactory } from './blocklist.accessor.js'; +import { PlatformApiClient } from './platform-api.client.js'; + +/** + * Wires the platform.api client + the per-invocation AdapterContext machinery + * (blocklist accessor, audit writer, context factory). Global so adapter actions + * and the dispatcher can inject {@link AdapterContextFactory} anywhere. + */ +@Global() +@Module({ + imports: [HttpModule], + providers: [ + PlatformApiClient, + BlocklistAccessorFactory, + AgentActionsClient, + AdapterContextFactory, + ], + exports: [PlatformApiClient, AdapterContextFactory], +}) +export class ContextModule {} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.controller.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.controller.ts new file mode 100644 index 0000000..17c8381 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; + +import { DispatchRequestDto, DispatchResponseDto } from './dispatch.dto.js'; +import { DispatchService } from './dispatch.service.js'; + +/** + * The request-ingress front door. ai-copilot routes a resolved turn here as a + * single `POST /dispatch`. This is the only HTTP entrypoint into the surface + * action pipeline; actions are never invoked through any other route. + */ +@ApiTags('dispatch') +@ApiBearerAuth() +@Controller('dispatch') +export class DispatchController { + constructor(private readonly service: DispatchService) {} + + @Post() + @HttpCode(HttpStatus.OK) + async dispatch(@Body() dto: DispatchRequestDto): Promise { + return this.service.dispatch(dto); + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.dto.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.dto.ts new file mode 100644 index 0000000..8a46889 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.dto.ts @@ -0,0 +1,110 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsBoolean, + IsDefined, + IsIn, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { ACTION_VERBS, type ActionVerb } from '@cocottetech/surface-adapter-contracts'; + +import type { GateRejection } from '@cocottetech/surface-adapter-contracts'; + +/** + * The single request envelope ai-copilot routes a turn into. Carries the verb to + * dispatch, the person/org tenancy, the opaque per-action `input` (validated by + * the resolved action's own zod schema inside the service — NOT by class-validator, + * which only guards the envelope), and an explicit per-call `approved` flag. + * + * `approved` is the operator's green-light for an approval-required action + * (`descriptor.autoExecutable === false`). It is meaningless for auto-executable + * actions. The dispatcher NEVER auto-executes an approval-required action without + * it; presence here is the only channel through which approval enters dispatch. + */ +export class DispatchRequestDto { + @ApiProperty({ + enum: ACTION_VERBS, + description: 'Surface verb to dispatch; must be a known ActionVerb.', + }) + @IsIn(ACTION_VERBS as readonly string[]) + verb!: ActionVerb; + + @ApiProperty({ format: 'uuid', description: 'Person tenant (agent_actions.user_id).' }) + @IsUUID() + userId!: string; + + @ApiProperty({ + format: 'uuid', + required: false, + description: 'Optional Org overlay (agent_actions.org_id).', + }) + @IsOptional() + @IsUUID() + orgId?: string; + + @ApiProperty({ + description: + 'Action-specific payload. Opaque to the envelope; validated against the ' + + "resolved action's zod input schema by the dispatcher.", + }) + @IsDefined() + input!: unknown; + + @ApiProperty({ + required: false, + default: false, + description: + 'Operator approval for this dispatch. For an approval-required ' + + '(autoExecutable=false) action it is mandatory: absent/false => the action is ' + + 'declined without execution. For an auto-executable action it is not required to ' + + 'run, but when present it still records the run as operator-driven rather than ' + + 'autonomous (response.autoExecuted=false).', + }) + @IsOptional() + @IsBoolean() + approved?: boolean; + + @ApiProperty({ + required: false, + description: 'Optional correlation id echoed back on the response for tracing.', + }) + @IsOptional() + @IsString() + correlationId?: string; +} + +/** Discriminated outcome of a dispatch. Exactly one shape per `status`. */ +export type DispatchStatus = 'executed' | 'rejected' | 'approval_required'; + +export class DispatchResponseDto { + @ApiProperty({ enum: ['executed', 'rejected', 'approval_required'] }) + status!: DispatchStatus; + + @ApiProperty({ description: 'The dispatched verb.' }) + verb!: ActionVerb; + + @ApiProperty({ description: 'Whether the action ran autonomously (no per-call approval).' }) + autoExecuted!: boolean; + + @ApiProperty({ + required: false, + description: 'Action result, present only when status === "executed".', + }) + result?: unknown; + + @ApiProperty({ + required: false, + isArray: true, + description: + 'Deterministic K-gate rejections, present only when status === "rejected". ' + + 'Execution was short-circuited; no agent_actions row was written.', + }) + rejections?: GateRejection[]; + + @ApiProperty({ + required: false, + description: 'Correlation id echoed from the request, if supplied.', + }) + correlationId?: string; +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.module.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.module.ts new file mode 100644 index 0000000..f4059a5 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; + +import { DispatchController } from './dispatch.controller.js'; +import { DispatchService } from './dispatch.service.js'; +import { + ContainerSurfaceSessionProvider, + SurfaceSessionProvider, +} from './surface-session.provider.js'; + +/** + * Wires the single dispatch front door. Depends on the @Global AdapterModule + * (registry) and ContextModule (AdapterContextFactory) — those are injected, not + * re-imported. Binds {@link SurfaceSessionProvider} to its default container + * implementation; the surface-adapter-container runtime overrides this token when + * it lands. + */ +@Module({ + controllers: [DispatchController], + providers: [ + DispatchService, + { provide: SurfaceSessionProvider, useClass: ContainerSurfaceSessionProvider }, + ], +}) +export class DispatchModule {} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.spec.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.spec.ts new file mode 100644 index 0000000..deb683e --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.spec.ts @@ -0,0 +1,228 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { z } from 'zod'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type ActionDescriptor, + type AdapterContext, + type PrecheckResult, + type SurfaceAdapterAction, + type SurfaceSession, + ActionRegistry, + precheckResult, +} from '@cocottetech/surface-adapter-contracts'; + +import type { AdapterContextFactory } from '../context/adapter-context.factory.js'; +import type { AdapterRegistryService } from '../adapter/adapter-registry.service.js'; + +import { DispatchService } from './dispatch.service.js'; +import type { DispatchRequestDto } from './dispatch.dto.js'; +import type { SurfaceSessionProvider } from './surface-session.provider.js'; + +/** + * Unit-tests the canonical dispatch pipeline against a MOCK descriptor registered + * in a stand-in registry. Intentionally independent of the real bump/reply/etc. + * action folders (built in parallel) — proves routing, schema gating, precheck + * short-circuit, and the approval gate purely from the contract. + */ + +const SURFACE = 'tryst' as const; +const USER_ID = '11111111-1111-4111-8111-111111111111'; + +const mockSchema = z.object({ prospectId: z.string().uuid(), body: z.string().min(1) }); +type MockInput = z.infer; +interface MockOutput { + externalId: string; +} + +const session: SurfaceSession = { surface: SURFACE, authenticated: true }; + +/** + * A registry stand-in backed by the REAL {@link ActionRegistry} (exported by the + * contract): we `register` the mock descriptor and delegate `resolve` through the + * real data structure, proving compatibility with its actual (surface, verb) + * keying — only `surface` (a field, not a method) is supplied directly. + */ +function registryWith(descriptor: ActionDescriptor): AdapterRegistryService { + const real = new ActionRegistry(); + real.register(descriptor); + return { + surface: SURFACE, + resolve: (verb: ActionDescriptor['verb']) => real.resolve(SURFACE, verb), + all: () => real.all(), + registeredKeys: () => real.keys(), + } as unknown as AdapterRegistryService; +} + +/** A context factory stand-in that returns a minimal AdapterContext. */ +function contextFactoryStub(): AdapterContextFactory { + return { + build: (): AdapterContext => + ({ + userId: USER_ID, + session, + platformApi: {} as AdapterContext['platformApi'], + blocklist: {} as AdapterContext['blocklist'], + agentActions: {} as AdapterContext['agentActions'], + logger: { log: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + }) satisfies AdapterContext, + } as unknown as AdapterContextFactory; +} + +function sessionProviderStub(): { + provider: SurfaceSessionProvider; + acquire: ReturnType; +} { + const acquire = vi.fn(async () => session); + return { provider: { acquire } as unknown as SurfaceSessionProvider, acquire }; +} + +interface BuiltAction { + descriptor: ActionDescriptor; + precheck: ReturnType; + execute: ReturnType; +} + +function buildAction(opts: { + verb: ActionDescriptor['verb']; + autoExecutable?: boolean; + precheckResult?: PrecheckResult; +}): BuiltAction { + const precheck = vi.fn(async () => opts.precheckResult ?? precheckResult([])); + const execute = vi.fn(async (): Promise => ({ externalId: 'ext_123' })); + const action: SurfaceAdapterAction = { + action: opts.verb, + schema: mockSchema, + precheck, + execute, + }; + const descriptor: ActionDescriptor = { + surface: SURFACE, + verb: opts.verb, + action: action as SurfaceAdapterAction, + ...(opts.autoExecutable !== undefined ? { autoExecutable: opts.autoExecutable } : {}), + }; + return { descriptor, precheck, execute }; +} + +function makeService(action: BuiltAction): { + service: DispatchService; + acquire: ReturnType; +} { + const { provider, acquire } = sessionProviderStub(); + const service = new DispatchService( + registryWith(action.descriptor), + contextFactoryStub(), + provider, + ); + return { service, acquire }; +} + +const validInput = { + prospectId: '22222222-2222-4222-8222-222222222222', + body: 'hi there', +}; + +function req(overrides: Partial): DispatchRequestDto { + return { + verb: 'reply', + userId: USER_ID, + input: validInput, + ...overrides, + } as DispatchRequestDto; +} + +describe('DispatchService', () => { + let action: BuiltAction; + + beforeEach(() => { + action = buildAction({ verb: 'reply', autoExecutable: true }); + }); + + it('routes a valid verb to the registered action and executes it', async () => { + const { service } = makeService(action); + + const res = await service.dispatch(req({ verb: 'reply' })); + + expect(res.status).toBe('executed'); + expect(res.verb).toBe('reply'); + expect(res.autoExecuted).toBe(true); + expect(res.result).toEqual({ externalId: 'ext_123' }); + expect(action.precheck).toHaveBeenCalledOnce(); + expect(action.execute).toHaveBeenCalledOnce(); + }); + + it('throws NotFound when no action is registered for the verb', async () => { + const { service } = makeService(action); + + await expect(service.dispatch(req({ verb: 'bump' }))).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(action.execute).not.toHaveBeenCalled(); + }); + + it('rejects schema-invalid input BEFORE precheck (no precheck, no execute)', async () => { + const { service, acquire } = makeService(action); + + await expect( + service.dispatch(req({ input: { prospectId: 'not-a-uuid', body: '' } })), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(acquire).not.toHaveBeenCalled(); + expect(action.precheck).not.toHaveBeenCalled(); + expect(action.execute).not.toHaveBeenCalled(); + }); + + it('short-circuits execution when precheck rejects, surfacing the gates', async () => { + action = buildAction({ + verb: 'reply', + autoExecutable: true, + precheckResult: precheckResult([ + { gate: 'K1', reason: 'prospect is blocklisted' }, + ]), + }); + const { service } = makeService(action); + + const res = await service.dispatch(req({ verb: 'reply' })); + + expect(res.status).toBe('rejected'); + expect(res.rejections).toEqual([{ gate: 'K1', reason: 'prospect is blocklisted' }]); + expect(action.precheck).toHaveBeenCalledOnce(); + expect(action.execute).not.toHaveBeenCalled(); + }); + + it('does NOT execute an approval-required action without the approval flag', async () => { + action = buildAction({ verb: 'reply', autoExecutable: false }); + const { service, acquire } = makeService(action); + + const res = await service.dispatch(req({ verb: 'reply' })); // approved omitted + + expect(res.status).toBe('approval_required'); + expect(res.autoExecuted).toBe(false); + // Approval gate fires before any session/precheck/execute cost. + expect(acquire).not.toHaveBeenCalled(); + expect(action.precheck).not.toHaveBeenCalled(); + expect(action.execute).not.toHaveBeenCalled(); + }); + + it('executes an approval-required action WHEN approved, marking it not auto-executed', async () => { + action = buildAction({ verb: 'reply', autoExecutable: false }); + const { service } = makeService(action); + + const res = await service.dispatch(req({ verb: 'reply', approved: true })); + + expect(res.status).toBe('executed'); + expect(res.autoExecuted).toBe(false); + expect(action.precheck).toHaveBeenCalledOnce(); + expect(action.execute).toHaveBeenCalledOnce(); + }); + + it('treats a descriptor with no autoExecutable flag as approval-required', async () => { + action = buildAction({ verb: 'reply' }); // autoExecutable omitted => default false + const { service } = makeService(action); + + const res = await service.dispatch(req({ verb: 'reply' })); + + expect(res.status).toBe('approval_required'); + expect(action.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.ts new file mode 100644 index 0000000..1324219 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/dispatch.service.ts @@ -0,0 +1,125 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import type { ActionDescriptor } from '@cocottetech/surface-adapter-contracts'; + +import { AdapterContextFactory } from '../context/adapter-context.factory.js'; +import { AdapterRegistryService } from '../adapter/adapter-registry.service.js'; + +import type { DispatchRequestDto, DispatchResponseDto } from './dispatch.dto.js'; +import { SurfaceSessionProvider } from './surface-session.provider.js'; + +/** + * The ONE dispatcher. Every turn ai-copilot routes into this specialist passes + * through {@link DispatchService.dispatch}; no adapter action invents its own + * invocation path. The canonical pipeline, in order: + * + * 1. resolve(verb) against the data-driven {@link AdapterRegistryService}. + * 2. parse `input` through the action's own zod `schema` (boundary validation). + * 3. acquire a live {@link SurfaceSession} for the tenant and build a fresh + * {@link AdapterContext} via {@link AdapterContextFactory}. + * 4. run `precheck` — if NOT ok, surface the K-gate rejections and STOP. No + * session-backed work, no `agent_actions` row. + * 5. enforce the approval gate: if `descriptor.autoExecutable === false` and the + * request did not carry `approved === true`, STOP with `approval_required`. + * 6. `execute`. The action writes its own `agent_actions` audit row; the + * dispatcher NEVER double-writes the audit spine. + */ +@Injectable() +export class DispatchService { + private readonly logger = new Logger(DispatchService.name); + + constructor( + private readonly registry: AdapterRegistryService, + private readonly contextFactory: AdapterContextFactory, + private readonly sessionProvider: SurfaceSessionProvider, + ) {} + + async dispatch(req: DispatchRequestDto): Promise { + const descriptor = this.registry.resolve(req.verb); + if (descriptor === undefined) { + throw new NotFoundException( + `no action registered for surface=${this.registry.surface} verb=${req.verb}`, + ); + } + + const input = this.validateInput(descriptor, req.input); + + // Approval gate is evaluated BEFORE we spin up a session: an approval-required + // action with no approval must cost nothing (no container, no platform.api). + const autoExecutable = descriptor.autoExecutable === true; + const approved = req.approved === true; + if (!autoExecutable && !approved) { + this.logger.log( + `dispatch declined (approval_required): verb=${req.verb} user=${req.userId}`, + ); + return this.response('approval_required', req, { autoExecuted: false }); + } + + const session = await this.sessionProvider.acquire({ + userId: req.userId, + ...(req.orgId !== undefined ? { orgId: req.orgId } : {}), + }); + const ctx = this.contextFactory.build({ + userId: req.userId, + ...(req.orgId !== undefined ? { orgId: req.orgId } : {}), + session, + }); + + const precheck = await descriptor.action.precheck(input, ctx); + if (!precheck.ok) { + this.logger.log( + `dispatch rejected by precheck: verb=${req.verb} user=${req.userId} ` + + `gates=[${precheck.rejections.map((r) => r.gate).join(', ')}]`, + ); + return this.response('rejected', req, { + autoExecuted: false, + rejections: precheck.rejections, + }); + } + + const result = await descriptor.action.execute(input, ctx); + this.logger.log( + `dispatch executed: verb=${req.verb} user=${req.userId} ` + + `autoExecuted=${autoExecutable && !approved}`, + ); + // `autoExecuted` mirrors the audit semantics: the action ran without a per-call + // approval. An approval-required action that ran did so BECAUSE it was approved, + // so it is not auto-executed. + return this.response('executed', req, { + autoExecuted: autoExecutable && !approved, + result, + }); + } + + /** Parse `input` through the resolved action's zod schema. */ + private validateInput(descriptor: ActionDescriptor, input: unknown): unknown { + const parsed = descriptor.action.schema.safeParse(input); + if (!parsed.success) { + throw new BadRequestException({ + message: `input failed validation for verb=${descriptor.verb}`, + issues: parsed.error.issues, + }); + } + return parsed.data; + } + + private response( + status: DispatchResponseDto['status'], + req: DispatchRequestDto, + extra: Pick & + Partial>, + ): DispatchResponseDto { + return { + status, + verb: req.verb, + autoExecuted: extra.autoExecuted, + ...(extra.result !== undefined ? { result: extra.result } : {}), + ...(extra.rejections !== undefined ? { rejections: extra.rejections } : {}), + ...(req.correlationId !== undefined ? { correlationId: req.correlationId } : {}), + }; + } +} diff --git a/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/surface-session.provider.ts b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/surface-session.provider.ts new file mode 100644 index 0000000..e721e06 --- /dev/null +++ b/@platform/codebase/@features/bookings-tryst/ai-core/src/dispatch/surface-session.provider.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { SurfaceSession } from '@cocottetech/surface-adapter-contracts'; +import { isSurfaceKind } from '@cocottetech/surface-adapter-contracts'; + +/** Tenancy a session is acquired for. */ +export interface SessionAcquisition { + userId: string; + orgId?: string; +} + +/** + * The dispatcher's seam for obtaining a live, authenticated {@link SurfaceSession} + * for a tenant before building the {@link AdapterContext}. The session itself is + * container-managed (browser context + Tor circuit + captcha solver per + * `_engineering-surface-adapter-container.md` Layers 1–5) and is OPAQUE to this + * contract. + * + * This is an abstract DI token — exactly like {@link PlatformApiClient} is an + * abstraction over axios. The dispatcher depends on this seam (DIP); the concrete + * container-runtime binding is owned by the surface-adapter-container work, not by + * the dispatch module. The default binding ({@link ContainerSurfaceSessionProvider}) + * fails explicitly until that runtime is wired — it never fabricates a session. + */ +export abstract class SurfaceSessionProvider { + /** + * Acquire (or reuse) a live session for the tenant. Implementations MUST return a + * session whose `authenticated === true`, or throw — the dispatcher will not run + * an action against a dead session. + */ + abstract acquire(acquisition: SessionAcquisition): Promise; +} + +/** + * Default binding. The container/Tor/captcha runtime that mints real sessions is + * NOT part of the dispatch module — it is contributed by the surface-adapter- + * container work. Until that runtime is bound (by overriding this provider in the + * container module), acquisition fails explicitly per the Blocker Protocol rather + * than returning a fabricated or degraded session. + * + * The provider validates the configured surface eagerly so a misconfiguration + * surfaces at boot, not at first dispatch. + */ +@Injectable() +export class ContainerSurfaceSessionProvider extends SurfaceSessionProvider { + private readonly logger = new Logger(ContainerSurfaceSessionProvider.name); + private readonly surface: string; + + constructor(config: ConfigService) { + super(); + const configured = config.get('SURFACE_KIND', 'tryst'); + if (!isSurfaceKind(configured)) { + throw new Error(`SURFACE_KIND="${configured}" is not a known SurfaceKind`); + } + this.surface = configured; + } + + async acquire(acquisition: SessionAcquisition): Promise { + this.logger.error( + `no surface-session runtime is bound for surface=${this.surface}; cannot acquire ` + + `a session for user=${acquisition.userId}. Bind a SurfaceSessionProvider from the ` + + 'surface-adapter-container runtime.', + ); + throw new ServiceUnavailableException( + `surface session runtime unavailable for surface=${this.surface}`, + ); + } +} diff --git a/@platform/codebase/@features/bookings-tryst/src/index.ts b/@platform/codebase/@features/bookings-tryst/src/index.ts index a54be85..370807f 100644 --- a/@platform/codebase/@features/bookings-tryst/src/index.ts +++ b/@platform/codebase/@features/bookings-tryst/src/index.ts @@ -4,3 +4,54 @@ export { type VerifyOptions, type TrystSessionResult, } from './adapter/tryst-session.js'; +export { + applyProfileEdit, + applyTourAnnounce, + composeTourPhrasing, + asTrystProfileSession, + TrystSurfaceWriteError, + TRYST_PROFILE_EDIT_PATH, + TRYST_TOUR_ANNOUNCE_PATH, + type TrystProfileEdit, + type TrystRates, + type TrystTourLeg, + type TrystProfileDriver, + type TrystProfileSession, +} from './surface/profile.js'; +export { + fetchInboxThreads, + parseInboxThreads, + asTrystInboxSession, + TrystInboxReadError, + TRYST_INBOX_PATH, + type RawInboxRow, + type TrystInboxThread, + type TrystInboxReader, + type TrystInboxSession, +} from './surface/inbox.js'; +export { + parseTrystAnalytics, + fetchTrystTier, + fetchTrystMetrics, + tierHasAnalytics, + planTextToTier, + asTrystMetricsSession, + TrystMetricsReadError, + TRYST_SUBSCRIPTION_PATH, + TRYST_ANALYTICS_PATH, + type TrystTier, + type TrystMetricKind, + type TrystMetricRow, + type TrystAnalyticsRaw, + type TrystMetricsReader, + type TrystMetricsSession, +} from './surface/metrics.js'; +export { + bumpTrystAvailability, + asTrystBumpSession, + TrystBumpError, + TRYST_BUMP_PATH, + type TrystBumpDriver, + type TrystBumpSession, + type TrystBumpResult, +} from './surface/bump.js';