refactor(ai-core): ♻️ Modularize adapter registry, context management, and dispatch system with dependency injection support
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
43b3aabfbd
commit
7f7e7e9c44
13 changed files with 1017 additions and 0 deletions
|
|
@ -0,0 +1,73 @@
|
|||
# `adapter/` — surface action folders (data-driven registry)
|
||||
|
||||
Each action is **one folder** here: `adapter/<verb>/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.
|
||||
|
||||
`<verb>` 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/<verb>/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<typeof schema>;
|
||||
interface Output {
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
const action: SurfaceAdapterAction<Input, Output> = {
|
||||
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<unknown, unknown>,
|
||||
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).
|
||||
|
|
@ -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/<verb>/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/<verb>/index.ts`:
|
||||
*
|
||||
* import { z } from 'zod';
|
||||
* import type { ActionDescriptor, SurfaceAdapterAction } from '@cocottetech/surface-adapter-contracts';
|
||||
*
|
||||
* const schema = z.object({ ... });
|
||||
* type Input = z.infer<typeof schema>;
|
||||
* interface Output { ... }
|
||||
*
|
||||
* const action: SurfaceAdapterAction<Input, Output> = {
|
||||
* action: 'bump',
|
||||
* schema,
|
||||
* async precheck(input, ctx) { ... },
|
||||
* async execute(input, ctx) { ... },
|
||||
* };
|
||||
*
|
||||
* export const descriptor: ActionDescriptor = {
|
||||
* surface: 'tryst',
|
||||
* verb: 'bump',
|
||||
* action: action as SurfaceAdapterAction<unknown, unknown>,
|
||||
* 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<string>('SURFACE_KIND', 'tryst');
|
||||
if (!isSurfaceKind(configured)) {
|
||||
throw new Error(`SURFACE_KIND="${configured}" is not a known SurfaceKind`);
|
||||
}
|
||||
this.surface = configured;
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
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/<dir>/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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<verb>/index.ts` folder — they do NOT edit this module.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AdapterRegistryService],
|
||||
exports: [AdapterRegistryService],
|
||||
})
|
||||
export class AdapterModule {}
|
||||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<readonly BlocklistEntry[]> {
|
||||
const query: Record<string, string> = { user_id: userId };
|
||||
if (orgId) {
|
||||
query['org_id'] = orgId;
|
||||
}
|
||||
if (kind) {
|
||||
query['kind'] = kind;
|
||||
}
|
||||
const rows = await platformApi.get<BlocklistEntryWire[]>('/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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<DispatchResponseDto> {
|
||||
return this.service.dispatch(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<typeof mockSchema>;
|
||||
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<typeof vi.fn>;
|
||||
} {
|
||||
const acquire = vi.fn(async () => session);
|
||||
return { provider: { acquire } as unknown as SurfaceSessionProvider, acquire };
|
||||
}
|
||||
|
||||
interface BuiltAction {
|
||||
descriptor: ActionDescriptor;
|
||||
precheck: ReturnType<typeof vi.fn>;
|
||||
execute: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
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<MockOutput> => ({ externalId: 'ext_123' }));
|
||||
const action: SurfaceAdapterAction<MockInput, MockOutput> = {
|
||||
action: opts.verb,
|
||||
schema: mockSchema,
|
||||
precheck,
|
||||
execute,
|
||||
};
|
||||
const descriptor: ActionDescriptor = {
|
||||
surface: SURFACE,
|
||||
verb: opts.verb,
|
||||
action: action as SurfaceAdapterAction<unknown, unknown>,
|
||||
...(opts.autoExecutable !== undefined ? { autoExecutable: opts.autoExecutable } : {}),
|
||||
};
|
||||
return { descriptor, precheck, execute };
|
||||
}
|
||||
|
||||
function makeService(action: BuiltAction): {
|
||||
service: DispatchService;
|
||||
acquire: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
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>): 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<DispatchResponseDto> {
|
||||
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<DispatchResponseDto, 'autoExecuted'> &
|
||||
Partial<Pick<DispatchResponseDto, 'result' | 'rejections'>>,
|
||||
): 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SurfaceSession>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string>('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<SurfaceSession> {
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue