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:
autocommit 2026-06-03 23:30:05 -07:00
parent 43b3aabfbd
commit 7f7e7e9c44
13 changed files with 1017 additions and 0 deletions

View file

@ -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).

View file

@ -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);
}
}

View file

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

View file

@ -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)),
};
}
}

View file

@ -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,
};
}

View file

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

View file

@ -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);
}
}

View file

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

View file

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

View file

@ -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();
});
});

View file

@ -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 } : {}),
};
}
}

View file

@ -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 15) 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}`,
);
}
}

View file

@ -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';