feat(ai-core): ✨ Introduce precheck context factory methods and dispatch service logic for handling precheck requests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
09d6b4c7b0
commit
8c41bdf61d
3 changed files with 235 additions and 76 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type {
|
||||
AdapterContext,
|
||||
AdapterLogger,
|
||||
PrecheckContext,
|
||||
SurfaceSession,
|
||||
} from '@cocottetech/surface-adapter-contracts';
|
||||
|
||||
|
|
@ -9,47 +11,71 @@ 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 {
|
||||
/** Identity a dispatch supplies to build a session-free {@link PrecheckContext}. */
|
||||
export interface PrecheckInvocation {
|
||||
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
|
||||
* Builds the per-invocation contexts every dispatched action receives, 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.
|
||||
* context every parallel agent's action sees — agents never build it.
|
||||
*
|
||||
* Two contexts mirror the split contract:
|
||||
* - {@link buildPrecheckContext} — session-free, for `precheck` (and for the
|
||||
* dispatcher's declined-row write). No session is acquired to build it.
|
||||
* - {@link buildExecuteContext} — the precheck context plus the live session and
|
||||
* the dispatcher's `autoExecuted` decision, for `execute`.
|
||||
*
|
||||
* The logger is surface-tagged from the configured `SURFACE_KIND` (not from a
|
||||
* session), so precheck can be logged before any session exists — mirroring how
|
||||
* `AdapterRegistryService` / `ContainerSurfaceSessionProvider` read the surface.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdapterContextFactory {
|
||||
private readonly surface: string;
|
||||
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly platformApi: PlatformApiClient,
|
||||
private readonly blocklistFactory: BlocklistAccessorFactory,
|
||||
private readonly agentActions: AgentActionsClient,
|
||||
) {}
|
||||
) {
|
||||
this.surface = config.get<string>('SURFACE_KIND', 'tryst');
|
||||
}
|
||||
|
||||
build(invocation: AdapterInvocation): AdapterContext {
|
||||
const { userId, orgId, session } = invocation;
|
||||
const logger = this.adapterLogger(userId, session.surface);
|
||||
|
||||
const context: AdapterContext = {
|
||||
/** Session-free context for `precheck` and the dispatcher's declined-row write. */
|
||||
buildPrecheckContext(invocation: PrecheckInvocation): PrecheckContext {
|
||||
const { userId, orgId } = invocation;
|
||||
const context: PrecheckContext = {
|
||||
userId,
|
||||
...(orgId !== undefined ? { orgId } : {}),
|
||||
session,
|
||||
platformApi: this.platformApi,
|
||||
blocklist: this.blocklistFactory.forTenant(userId, orgId),
|
||||
agentActions: this.agentActions,
|
||||
logger,
|
||||
logger: this.adapterLogger(userId),
|
||||
};
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full execute context: the already-built {@link PrecheckContext} plus the live
|
||||
* session and the dispatcher's `autoExecuted` decision. Reuses the precheck
|
||||
* context's collaborators (blocklist accessor, logger) — does NOT rebuild them.
|
||||
*/
|
||||
buildExecuteContext(
|
||||
precheck: PrecheckContext,
|
||||
session: SurfaceSession,
|
||||
autoExecuted: boolean,
|
||||
): AdapterContext {
|
||||
return { ...precheck, session, autoExecuted };
|
||||
}
|
||||
|
||||
/** A contract {@link AdapterLogger} backed by NestJS's Logger, surface-tagged. */
|
||||
private adapterLogger(userId: string, surface: string): AdapterLogger {
|
||||
const nest = new Logger(`adapter:${surface}`);
|
||||
private adapterLogger(userId: string): AdapterLogger {
|
||||
const nest = new Logger(`adapter:${this.surface}`);
|
||||
const tag = (msg: string): string => `[user=${userId}] ${msg}`;
|
||||
return {
|
||||
log: (msg) => nest.log(tag(msg)),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
type ActionDescriptor,
|
||||
type AdapterContext,
|
||||
type AgentActionWrite,
|
||||
type PrecheckContext,
|
||||
type PrecheckResult,
|
||||
type SurfaceAdapterAction,
|
||||
type SurfaceSession,
|
||||
|
|
@ -53,18 +55,30 @@ function registryWith(descriptor: ActionDescriptor): AdapterRegistryService {
|
|||
} as unknown as AdapterRegistryService;
|
||||
}
|
||||
|
||||
/** A context factory stand-in that returns a minimal AdapterContext. */
|
||||
function contextFactoryStub(): AdapterContextFactory {
|
||||
/**
|
||||
* A context factory stand-in mirroring the split contract. `buildPrecheckContext`
|
||||
* returns a session-free {@link PrecheckContext}; `buildExecuteContext` adds the
|
||||
* session and — crucially — sets `autoExecuted` FROM ITS ARGUMENT, so a test can
|
||||
* verify the dispatcher passed its decision through (not a hardcode). Both contexts
|
||||
* share ONE `record` spy via `agentActions`, so a test sees every audit write —
|
||||
* whether the dispatcher's declined row or the action's `execute` row.
|
||||
*/
|
||||
function contextFactoryStub(record: ReturnType<typeof vi.fn>): AdapterContextFactory {
|
||||
const agentActions = { record } as unknown as PrecheckContext['agentActions'];
|
||||
const base = (): PrecheckContext => ({
|
||||
userId: USER_ID,
|
||||
platformApi: {} as PrecheckContext['platformApi'],
|
||||
blocklist: {} as PrecheckContext['blocklist'],
|
||||
agentActions,
|
||||
logger: { log: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
});
|
||||
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,
|
||||
buildPrecheckContext: (): PrecheckContext => base(),
|
||||
buildExecuteContext: (
|
||||
precheck: PrecheckContext,
|
||||
sess: SurfaceSession,
|
||||
autoExecuted: boolean,
|
||||
): AdapterContext => ({ ...precheck, session: sess, autoExecuted }),
|
||||
} as unknown as AdapterContextFactory;
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +102,22 @@ function buildAction(opts: {
|
|||
precheckResult?: PrecheckResult;
|
||||
}): BuiltAction {
|
||||
const precheck = vi.fn(async () => opts.precheckResult ?? precheckResult([]));
|
||||
const execute = vi.fn(async (): Promise<MockOutput> => ({ externalId: 'ext_123' }));
|
||||
// The action writes its OWN audit row on execute, stamping `auto_executed` from
|
||||
// the context the dispatcher built — so the persisted flag == the dispatcher's
|
||||
// decision (GAP 1). The test inspects this via the shared `record` spy.
|
||||
const execute = vi.fn(async (_input: MockInput, ctx: AdapterContext): Promise<MockOutput> => {
|
||||
await ctx.agentActions.record({
|
||||
userId: ctx.userId,
|
||||
specialistId: 'bookings-tryst',
|
||||
actionType: opts.verb,
|
||||
targetKind: 'prospect',
|
||||
stakes: 'medium',
|
||||
confidence: 1,
|
||||
autoExecuted: ctx.autoExecuted,
|
||||
outcome: { external_id: 'ext_123' },
|
||||
});
|
||||
return { externalId: 'ext_123' };
|
||||
});
|
||||
const action: SurfaceAdapterAction<MockInput, MockOutput> = {
|
||||
action: opts.verb,
|
||||
schema: mockSchema,
|
||||
|
|
@ -99,6 +128,8 @@ function buildAction(opts: {
|
|||
surface: SURFACE,
|
||||
verb: opts.verb,
|
||||
action: action as SurfaceAdapterAction<unknown, unknown>,
|
||||
auditTargetKind: 'prospect',
|
||||
auditStakes: 'medium',
|
||||
...(opts.autoExecutable !== undefined ? { autoExecutable: opts.autoExecutable } : {}),
|
||||
};
|
||||
return { descriptor, precheck, execute };
|
||||
|
|
@ -107,14 +138,19 @@ function buildAction(opts: {
|
|||
function makeService(action: BuiltAction): {
|
||||
service: DispatchService;
|
||||
acquire: ReturnType<typeof vi.fn>;
|
||||
record: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const { provider, acquire } = sessionProviderStub();
|
||||
const record = vi.fn(async (_w: AgentActionWrite) => ({
|
||||
id: 'aa_1',
|
||||
createdAt: '2026-06-03T00:00:00.000Z',
|
||||
}));
|
||||
const service = new DispatchService(
|
||||
registryWith(action.descriptor),
|
||||
contextFactoryStub(),
|
||||
contextFactoryStub(record),
|
||||
provider,
|
||||
);
|
||||
return { service, acquire };
|
||||
return { service, acquire, record };
|
||||
}
|
||||
|
||||
const validInput = {
|
||||
|
|
@ -139,7 +175,7 @@ describe('DispatchService', () => {
|
|||
});
|
||||
|
||||
it('routes a valid verb to the registered action and executes it', async () => {
|
||||
const { service } = makeService(action);
|
||||
const { service, record } = makeService(action);
|
||||
|
||||
const res = await service.dispatch(req({ verb: 'reply' }));
|
||||
|
||||
|
|
@ -149,6 +185,10 @@ describe('DispatchService', () => {
|
|||
expect(res.result).toEqual({ externalId: 'ext_123' });
|
||||
expect(action.precheck).toHaveBeenCalledOnce();
|
||||
expect(action.execute).toHaveBeenCalledOnce();
|
||||
// GAP 1 (c): the persisted audit flag equals the dispatcher's decision — an
|
||||
// auto action with no approval ran autonomously ⇒ auto_executed === true.
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record.mock.calls[0]?.[0]?.autoExecuted).toBe(true);
|
||||
});
|
||||
|
||||
it('throws NotFound when no action is registered for the verb', async () => {
|
||||
|
|
@ -172,7 +212,7 @@ describe('DispatchService', () => {
|
|||
expect(action.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('short-circuits execution when precheck rejects, surfacing the gates', async () => {
|
||||
it('short-circuits execution when precheck rejects, surfacing the gates — WITHOUT acquiring a session, writing one declined row (gate labels only)', async () => {
|
||||
action = buildAction({
|
||||
verb: 'reply',
|
||||
autoExecutable: true,
|
||||
|
|
@ -180,7 +220,7 @@ describe('DispatchService', () => {
|
|||
{ gate: 'K1', reason: 'prospect is blocklisted' },
|
||||
]),
|
||||
});
|
||||
const { service } = makeService(action);
|
||||
const { service, acquire, record } = makeService(action);
|
||||
|
||||
const res = await service.dispatch(req({ verb: 'reply' }));
|
||||
|
||||
|
|
@ -188,25 +228,64 @@ describe('DispatchService', () => {
|
|||
expect(res.rejections).toEqual([{ gate: 'K1', reason: 'prospect is blocklisted' }]);
|
||||
expect(action.precheck).toHaveBeenCalledOnce();
|
||||
expect(action.execute).not.toHaveBeenCalled();
|
||||
|
||||
// GAP 2 (a): precheck runs and rejects with NO session acquisition.
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
|
||||
// GAP 3 (b): the dispatcher writes the SINGLE declined row — gate LABELS only,
|
||||
// never the matched secret/draft. actionType is the verb; targetKind/stakes
|
||||
// mirror the descriptor.
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
const declined = record.mock.calls[0]?.[0];
|
||||
expect(declined?.actionType).toBe('reply');
|
||||
expect(declined?.targetKind).toBe('prospect');
|
||||
expect(declined?.stakes).toBe('medium');
|
||||
expect(declined?.autoExecuted).toBe(false);
|
||||
expect(declined?.outcome).toEqual({ declined: true, gates: ['K1'] });
|
||||
// No leaked secret/draft fields on the declined row.
|
||||
expect(declined?.outcome).not.toHaveProperty('draft');
|
||||
expect(declined?.outcome).not.toHaveProperty('reason');
|
||||
});
|
||||
|
||||
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 { service, acquire, record } = 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.
|
||||
// Precheck now runs FIRST (cleared here), but the approval gate still costs no
|
||||
// session, no execute, and writes no audit row (only a DECLINE writes a row).
|
||||
expect(action.precheck).toHaveBeenCalledOnce();
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(action.precheck).not.toHaveBeenCalled();
|
||||
expect(action.execute).not.toHaveBeenCalled();
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('executes an approval-required action WHEN approved, marking it not auto-executed', async () => {
|
||||
it('returns rejected (not approval_required) when an approval-required action ALSO fails a K-gate — without a session', async () => {
|
||||
action = buildAction({
|
||||
verb: 'reply',
|
||||
autoExecutable: false,
|
||||
precheckResult: precheckResult([{ gate: 'K1', reason: 'prospect is blocklisted' }]),
|
||||
});
|
||||
const { service, acquire, record } = makeService(action);
|
||||
|
||||
const res = await service.dispatch(req({ verb: 'reply' })); // approved omitted
|
||||
|
||||
// Precheck runs before the approval gate, so the K-gate decline wins.
|
||||
expect(res.status).toBe('rejected');
|
||||
expect(res.rejections).toEqual([{ gate: 'K1', reason: 'prospect is blocklisted' }]);
|
||||
expect(acquire).not.toHaveBeenCalled();
|
||||
expect(action.execute).not.toHaveBeenCalled();
|
||||
// One declined row, gate labels only.
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record.mock.calls[0]?.[0]?.outcome).toEqual({ declined: true, gates: ['K1'] });
|
||||
});
|
||||
|
||||
it('executes an approval-required action WHEN approved, marking it not auto-executed in BOTH the response and the persisted row', async () => {
|
||||
action = buildAction({ verb: 'reply', autoExecutable: false });
|
||||
const { service } = makeService(action);
|
||||
const { service, record } = makeService(action);
|
||||
|
||||
const res = await service.dispatch(req({ verb: 'reply', approved: true }));
|
||||
|
||||
|
|
@ -214,6 +293,10 @@ describe('DispatchService', () => {
|
|||
expect(res.autoExecuted).toBe(false);
|
||||
expect(action.precheck).toHaveBeenCalledOnce();
|
||||
expect(action.execute).toHaveBeenCalledOnce();
|
||||
// GAP 1 (c): an approval-required action that ran did so BECAUSE it was
|
||||
// approved ⇒ the persisted auto_executed === false, matching the response.
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record.mock.calls[0]?.[0]?.autoExecuted).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a descriptor with no autoExecutable flag as approval-required', async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import {
|
|||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { ActionDescriptor } from '@cocottetech/surface-adapter-contracts';
|
||||
import type {
|
||||
ActionDescriptor,
|
||||
GateRejection,
|
||||
PrecheckContext,
|
||||
} from '@cocottetech/surface-adapter-contracts';
|
||||
|
||||
import { AdapterContextFactory } from '../context/adapter-context.factory.js';
|
||||
import { AdapterRegistryService } from '../adapter/adapter-registry.service.js';
|
||||
|
|
@ -12,21 +16,34 @@ import { AdapterRegistryService } from '../adapter/adapter-registry.service.js';
|
|||
import type { DispatchRequestDto, DispatchResponseDto } from './dispatch.dto.js';
|
||||
import { SurfaceSessionProvider } from './surface-session.provider.js';
|
||||
|
||||
/** Specialist this dispatcher serves; stamped on the declined audit row it owns. */
|
||||
const SPECIALIST_ID = 'bookings-tryst';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 1. resolve(verb) against the data-driven {@link AdapterRegistryService};
|
||||
* 404 (NotFound) if none.
|
||||
* 2. parse `input` through the action's own zod `schema` (boundary validation);
|
||||
* 400 (BadRequest) if it fails.
|
||||
* 3. build a SESSION-FREE {@link PrecheckContext} (NO session acquisition) and
|
||||
* run `precheck`. If NOT ok: the dispatcher writes the SINGLE declined
|
||||
* `agent_actions` row (gate labels only — never the matched secret/draft,
|
||||
* per K3k) and returns `rejected`. No session was ever spun up.
|
||||
* 4. approval gate: if `descriptor.autoExecutable === false` and the request did
|
||||
* not carry `approved === true`, STOP with `approval_required` — still no
|
||||
* session. (A K-gate failure is caught in step 3 first, so an
|
||||
* approval-required action that ALSO fails a gate correctly returns
|
||||
* `rejected`, not `approval_required`.)
|
||||
* 5. acquire a live {@link SurfaceSession}, build the full {@link AdapterContext}
|
||||
* (carrying the dispatcher's `autoExecuted` decision), and `execute`.
|
||||
*
|
||||
* Audit invariant: the ACTION writes its own `agent_actions` row on `execute`
|
||||
* (with `auto_executed: ctx.autoExecuted`); the DISPATCHER writes ONLY the
|
||||
* declined row on a precheck rejection. Because `execute` never runs on a decline,
|
||||
* there is no double-write — this is the one case the dispatcher owns the audit.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DispatchService {
|
||||
|
|
@ -48,8 +65,31 @@ export class DispatchService {
|
|||
|
||||
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).
|
||||
// Build the SESSION-FREE precheck context — NO session is acquired here. A
|
||||
// K-gate rejection (and an approval-required hold) must cost no container.
|
||||
const precheckCtx = this.contextFactory.buildPrecheckContext({
|
||||
userId: req.userId,
|
||||
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
|
||||
});
|
||||
|
||||
const precheck = await descriptor.action.precheck(input, precheckCtx);
|
||||
if (!precheck.ok) {
|
||||
this.logger.log(
|
||||
`dispatch rejected by precheck: verb=${req.verb} user=${req.userId} ` +
|
||||
`gates=[${precheck.rejections.map((r) => r.gate).join(', ')}]`,
|
||||
);
|
||||
// The ACTION's execute never runs on a decline, so it cannot write the audit
|
||||
// row — the dispatcher writes the SINGLE declined row here. Gate labels only:
|
||||
// never echo the matched secret/draft (K3k).
|
||||
await this.recordDeclined(descriptor, req, precheckCtx, precheck.rejections);
|
||||
return this.response('rejected', req, {
|
||||
autoExecuted: false,
|
||||
rejections: precheck.rejections,
|
||||
});
|
||||
}
|
||||
|
||||
// Approval gate — still session-free. An approval-required action with no
|
||||
// approval costs nothing (no container, no execute).
|
||||
const autoExecutable = descriptor.autoExecutable === true;
|
||||
const approved = req.approved === true;
|
||||
if (!autoExecutable && !approved) {
|
||||
|
|
@ -59,39 +99,49 @@ export class DispatchService {
|
|||
return this.response('approval_required', req, { autoExecuted: false });
|
||||
}
|
||||
|
||||
// `autoExecuted` is the dispatcher's single decision: 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. The SAME expression feeds both the
|
||||
// execute context (→ the action's audit row) and the response, guaranteeing the
|
||||
// persisted `auto_executed` equals the dispatcher's decision (GAP 1).
|
||||
const autoExecuted = autoExecutable && !approved;
|
||||
|
||||
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 ctx = this.contextFactory.buildExecuteContext(precheckCtx, session, autoExecuted);
|
||||
|
||||
const result = await descriptor.action.execute(input, ctx);
|
||||
this.logger.log(
|
||||
`dispatch executed: verb=${req.verb} user=${req.userId} ` +
|
||||
`autoExecuted=${autoExecutable && !approved}`,
|
||||
`dispatch executed: verb=${req.verb} user=${req.userId} autoExecuted=${autoExecuted}`,
|
||||
);
|
||||
// `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,
|
||||
return this.response('executed', req, { autoExecuted, result });
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the SINGLE declined `agent_actions` row on a precheck rejection. This is
|
||||
* the one case the dispatcher owns the audit spine: `execute` never ran, so the
|
||||
* action could not record it. The outcome carries gate LABELS only — never the
|
||||
* matched secret / draft body (K3k). `targetKind` / `stakes` come from the
|
||||
* descriptor so the declined row mirrors the action's own execute row.
|
||||
*/
|
||||
private async recordDeclined(
|
||||
descriptor: ActionDescriptor,
|
||||
req: DispatchRequestDto,
|
||||
ctx: PrecheckContext,
|
||||
rejections: readonly GateRejection[],
|
||||
): Promise<void> {
|
||||
await ctx.agentActions.record({
|
||||
userId: req.userId,
|
||||
...(req.orgId !== undefined ? { orgId: req.orgId } : {}),
|
||||
specialistId: SPECIALIST_ID,
|
||||
actionType: req.verb,
|
||||
targetKind: descriptor.auditTargetKind,
|
||||
stakes: descriptor.auditStakes,
|
||||
confidence: 1,
|
||||
autoExecuted: false,
|
||||
outcome: { declined: true, gates: rejections.map((r) => r.gate) },
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue