De-hardcode orchestrator and interaction agent names (#748)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #748.
This commit is contained in:
2026-07-13 18:59:27 +00:00
parent 8dd4e9d541
commit 405984af5a
33 changed files with 579 additions and 304 deletions

View File

@@ -7,7 +7,7 @@ import {
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.js';
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
);
}
describe('Tess Discord/CLI durable-session integration', () => {
describe('interaction Discord/CLI durable-session integration', () => {
afterEach(() => {
for (const key of envKeys) {
const value = priorEnv.get(key);
@@ -80,7 +80,7 @@ describe('Tess Discord/CLI durable-session integration', () => {
},
]);
const durable = new TessDurableSessionService(
const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never,
{} as never,
);

View File

@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
@@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
TessDurableSessionRepository,
TessDurableSessionService,
DurableSessionRepository,
DurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
@@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
TessDurableSessionService,
DurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],

View File

@@ -1,7 +1,7 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface TessProviderOutboxDto {
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;

View File

@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
runtimeSessionId: 'nova',
};
describe('TessDurableSessionRepository', () => {
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
let previousAuthSecret: string | undefined;
@@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => {
});
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
const beforeRestart = new DurableSessionCoordinator(
new TessDurableSessionRepository(handle.db),
);
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
await handle.close();
handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
const recovered = await afterRestart.recover(IDENTITY.sessionId);
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('redacts sensitive durable payloads before persistence', async () => {
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('fails closed when the configured idempotency secret is unavailable', async () => {
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const input = {
sessionId: IDENTITY.sessionId,
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
const inbox = {
sessionId: IDENTITY.sessionId,
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY);
await coordinator.receive({
sessionId: IDENTITY.sessionId,
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
const repository = new TessDurableSessionRepository(handle.db);
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
const repository = new TessDurableSessionRepository(handle.db);
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
const repository = new TessDurableSessionRepository(handle.db);
const repository = new DurableSessionRepository(handle.db);
const coordinator = new DurableSessionCoordinator(repository);
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',

View File

@@ -33,7 +33,7 @@ import type {
import { DB } from '../database/database.module.js';
@Injectable()
export class TessDurableSessionRepository implements DurableSessionStore {
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
const existing = await this.session(identity.sessionId);
if (!existing || !sameEnrollmentScope(existing, identity)) {
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
}
// A recovered/re-enrolled runtime can receive a new provider session ID;
// the conversation handle and owner scope remain immutable.
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
),
)
.limit(1);
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
const entry = toInbox(existing[0]);
if (
!sameInbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
),
)
.limit(1);
if (!existing[0])
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
) {
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
}
@@ -360,7 +359,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
async handoff(input: DurableHandoffInput): Promise<void> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
if (!checkpoint) {
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const inserted = await this.db
.insert(interactionHandoffs)
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
const existing = await this.findHandoff(input.handoffId);
if (!existing || !sameHandoff(existing, input)) {
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
}

View File

@@ -1,23 +1,23 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class TessDurableSessionService {
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId
) {
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
throw new ForbiddenException('Durable session enrollment scope mismatch');
}
await this.coordinator.create(identity);
}
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.enqueueOutbox({
@@ -50,9 +50,9 @@ export class TessDurableSessionService {
});
}
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable Tess outbox session mismatch');
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
@@ -92,7 +92,7 @@ export class TessDurableSessionService {
}
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: TessProviderOutboxDto,
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
}
}
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
if (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}

View File

@@ -21,8 +21,8 @@ import {
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { AgentService } from './agent.service.js';
import { ProviderService } from './provider.service.js';
import { ProviderCredentialsService } from './provider-credentials.service.js';
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(TessDurableSessionService)
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(TessDurableSessionRepository)
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})

View File

@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
@@ -34,7 +34,7 @@ import {
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')

View File

@@ -37,7 +37,7 @@ import {
RuntimeProviderService,
type RuntimeAuditSink,
} from '../agent/runtime-provider-registry.service.js';
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
import { DurableSessionService } from '../agent/durable-session.service.js';
import { AUTH } from '../auth/auth.tokens.js';
import {
scopeFromUser,
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@Inject(RuntimeProviderService)
private readonly runtimeRegistry: RuntimeProviderService | null = null,
@Optional()
@Inject(TessDurableSessionService)
private readonly durableSessions: TessDurableSessionService | null = null,
@Inject(DurableSessionService)
private readonly durableSessions: DurableSessionService | null = null,
@Optional()
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly runtimeAudit: RuntimeAuditSink | null = null,

View File

@@ -1,31 +1,32 @@
import { Module } from '@nestjs/common';
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { MosCoordinationController } from './mos-coordination.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
MOS_COORDINATION_CONFIG,
MOS_COORDINATION_PORT,
MosCoordinationService,
} from './mos-coordination.service.js';
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [
CoordService,
{
provide: MOS_COORDINATION_PORT,
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
provide: COORDINATION_PORT,
useFactory: (): InMemoryInteractionCoordinationPort =>
new InMemoryInteractionCoordinationPort(),
},
{
provide: MOS_COORDINATION_CONFIG,
provide: COORDINATION_CONFIG,
useFactory: () => ({
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
}),
},
MosCoordinationService,
InteractionCoordinationService,
],
controllers: [CoordController, MosCoordinationController],
exports: [CoordService, MosCoordinationService],
controllers: [CoordController, InteractionCoordinationController],
exports: [CoordService, InteractionCoordinationService],
})
export class CoordModule {}

View File

@@ -1,16 +1,27 @@
const PATH_METADATA = 'path';
import { describe, expect, it, vi } from 'vitest';
import { MosCoordinationController } from './mos-coordination.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('MosCoordinationController', () => {
describe('InteractionCoordinationController', () => {
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
'api/coord/interaction',
'api/coord/mos',
]);
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
});
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(),
result: vi.fn(),
};
const controller = new MosCoordinationController(coordination as never);
const controller = new InteractionCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
@@ -26,7 +37,7 @@ describe('MosCoordinationController', () => {
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new MosCoordinationController(coordination as never);
const controller = new InteractionCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),

View File

@@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
MosCoordinationObservationDto,
MosCoordinationResponseDto,
MosCoordinationResultDto,
CreateMosHandoffDto,
} from './mos-coordination.dto.js';
import { MosCoordinationService } from './mos-coordination.service.js';
InteractionCoordinationObservationDto,
InteractionCoordinationResponseDto,
InteractionCoordinationResultDto,
CreateHandoffDto,
} from './interaction-coordination.dto.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
@Controller('api/coord/mos')
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
@Controller(['api/coord/interaction', 'api/coord/mos'])
@UseGuards(AuthGuard)
export class MosCoordinationController {
export class InteractionCoordinationController {
constructor(
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
@Inject(InteractionCoordinationService)
private readonly coordination: InteractionCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateMosHandoffDto,
@Body() request: CreateHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResponseDto> {
): Promise<InteractionCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@@ -43,7 +45,7 @@ export class MosCoordinationController {
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationObservationDto> {
): Promise<InteractionCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
@@ -54,7 +56,7 @@ export class MosCoordinationController {
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResultDto> {
): Promise<InteractionCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}

View File

@@ -1,25 +1,25 @@
import type {
CoordinationObservation,
CoordinationResult,
MosHandoffReceipt,
HandoffReceipt,
} from '@mosaicstack/coord';
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
export interface CreateMosHandoffDto {
export interface CreateHandoffDto {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
export interface MosCoordinationResponseDto {
receipt: MosHandoffReceipt;
export interface InteractionCoordinationResponseDto {
receipt: HandoffReceipt;
}
export interface MosCoordinationObservationDto {
export interface InteractionCoordinationObservationDto {
observation: CoordinationObservation;
}
export interface MosCoordinationResultDto {
export interface InteractionCoordinationResultDto {
result: CoordinationResult;
}

View File

@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Global, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { AUTH } from '../auth/auth.tokens.js';
import { AuthGuard } from '../auth/auth.guard.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
@Global()
@Module({
providers: [
{
provide: AUTH,
useValue: {
api: {
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
headers.get('cookie') === 'session=trusted'
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
: null,
),
},
},
},
AuthGuard,
],
exports: [AUTH, AuthGuard],
})
class AuthenticatedRequestModule {}
describe('InteractionCoordinationController route aliases', (): void => {
let app: NestFastifyApplication | undefined;
const coordination = {
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
observe: vi.fn(async () => ({ status: 'running' })),
result: vi.fn(async () => ({ status: 'completed' })),
};
beforeAll(async (): Promise<void> => {
const moduleRef = await Test.createTestingModule({
imports: [AuthenticatedRequestModule],
controllers: [InteractionCoordinationController],
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
}).compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async (): Promise<void> => app?.close());
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
if (!app) throw new Error('test app was not initialized');
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
expect(
(
await app.inject({
method: 'POST',
url: `${prefix}/handoff`,
headers,
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
})
).statusCode,
).toBe(201);
expect(
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
.statusCode,
).toBe(200);
expect(
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
.statusCode,
).toBe(200);
}
expect(coordination.handoff).toHaveBeenCalledTimes(2);
expect(coordination.observe).toHaveBeenCalledTimes(2);
expect(coordination.result).toHaveBeenCalledTimes(2);
expect(
(
await app.inject({
method: 'POST',
url: '/api/coord/interaction/handoff',
payload: { idempotencyKey: 'denied', summary: 'x' },
})
).statusCode,
).toBe(401);
});
});

View File

@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryMosCoordinationPort,
type MosCoordinationPort,
type MosHandoff,
InMemoryInteractionCoordinationPort,
type InteractionCoordinationPort,
type Handoff,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import {
MosCoordinationService,
type MosCoordinationConfig,
type MosCoordinationGatewayError,
} from './mos-coordination.service.js';
InteractionCoordinationService,
type InteractionCoordinationConfig,
type InteractionCoordinationGatewayError,
} from './interaction-coordination.service.js';
const context: RuntimeProviderRequestContext = {
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
correlationId: 'corr-1',
};
const config: MosCoordinationConfig = {
const config: InteractionCoordinationConfig = {
interactionAgentId: 'Nova',
orchestrationAgentId: 'Conductor',
};
function service(
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
options: {
config?: MosCoordinationConfig;
config?: InteractionCoordinationConfig;
handoffIdFactory?: () => string;
} = {},
): MosCoordinationService {
return new MosCoordinationService(
): InteractionCoordinationService {
return new InteractionCoordinationService(
port,
options.config ?? config,
options.handoffIdFactory ?? (() => 'handoff-1'),
);
}
describe('MosCoordinationService authority boundary', (): void => {
describe('InteractionCoordinationService authority boundary', (): void => {
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = service(adapter);
await expect(
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
adapter.recordActivity('handoff-1', 'running', 'Orchestrator accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
const followUpContext = { ...context, correlationId: 'corr-2' };
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
@@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => {
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
summary: 'Merged by Mos',
summary: 'Merged by orchestrator',
});
});
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
),
).rejects.toMatchObject({
code: 'unconfigured_requester',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).not.toHaveBeenCalled();
});
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
@@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => {
});
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const observe = vi.spyOn(adapter, 'observe');
const result = vi.spyOn(adapter, 'result');
const coordination = service(adapter);
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
};
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(observe).not.toHaveBeenCalled();
expect(result).not.toHaveBeenCalled();
});
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
const delivered = new Promise<void>((resolve: () => void): void => {
release = resolve;
});
const adapter: MosCoordinationPort = {
handoff: vi.fn(async (handoff: MosHandoff) => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => {
await delivered;
return {
handoffId: handoff.handoffId,
@@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => {
});
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter);
await coordination.handoff(
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
).rejects.toMatchObject({
code: 'handoff_conflict',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).toHaveBeenCalledTimes(1);
});
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
const adapter: MosCoordinationPort = {
handoff: vi.fn(async (handoff: MosHandoff) => ({
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => ({
handoffId: handoff.handoffId,
targetAgentId: 'Unexpected',
status: 'accepted' as const,

View File

@@ -1,18 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import {
MosCoordinationClient,
InteractionCoordinationClient,
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type MosCoordinationIdentity,
type MosCoordinationPort,
type MosHandoffReceipt,
type InteractionCoordinationIdentity,
type InteractionCoordinationPort,
type HandoffReceipt,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000;
@@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048;
const MAX_CONTEXT_LENGTH = 8_192;
const MAX_MISSION_ID_LENGTH = 128;
export interface MosCoordinationConfig {
export interface InteractionCoordinationConfig {
interactionAgentId?: string;
orchestrationAgentId?: string;
}
@@ -34,7 +34,7 @@ interface HandoffOwner {
expiresAt: number;
}
interface NormalizedMosHandoffRequest {
interface NormalizedHandoffRequest {
idempotencyKey: string;
summary: string;
context?: string;
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
}
interface TrackedHandoff {
request: NormalizedMosHandoffRequest;
receipt: Promise<MosHandoffReceipt>;
request: NormalizedHandoffRequest;
receipt: Promise<HandoffReceipt>;
expiresAt: number;
}
/**
* Gateway authority boundary for the interaction agent. It derives requester,
* actor, and tenant from trusted server configuration and authentication; no
* channel request can name a target or gain Mos-owned orchestration verbs.
* channel request can name a target or gain orchestrator-owned orchestration verbs.
*/
@Injectable()
export class MosCoordinationService {
export class InteractionCoordinationService {
private readonly owners = new Map<string, HandoffOwner>();
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
constructor(
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
async handoff(
request: CreateMosHandoffDto,
request: CreateHandoffDto,
context: RuntimeProviderRequestContext,
): Promise<MosHandoffReceipt> {
): Promise<HandoffReceipt> {
this.pruneExpiredTracking();
const normalized = this.normalizeRequest(request);
const scope = this.scope(context);
@@ -74,9 +74,9 @@ export class MosCoordinationService {
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
if (existing !== undefined) {
if (!sameRequest(existing.request, normalized)) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Mos handoff idempotency key is already bound to different immutable input',
'Handoff idempotency key is already bound to different immutable input',
);
}
return existing.receipt;
@@ -122,9 +122,9 @@ export class MosCoordinationService {
private async deliverHandoff(
handoffId: string,
request: NormalizedMosHandoffRequest,
request: NormalizedHandoffRequest,
scope: CoordinationScope,
): Promise<MosHandoffReceipt> {
): Promise<HandoffReceipt> {
const receipt = await this.client((): string => handoffId).handoff(request, scope);
const owner: HandoffOwner = {
actorId: scope.actorId,
@@ -135,9 +135,9 @@ export class MosCoordinationService {
};
const existing = this.owners.get(receipt.handoffId);
if (existing !== undefined && !sameOwner(existing, owner)) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Mos handoff ID is already bound to a different authenticated scope',
'Handoff ID is already bound to a different authenticated scope',
);
}
this.owners.set(receipt.handoffId, owner);
@@ -145,21 +145,21 @@ export class MosCoordinationService {
return receipt;
}
private client(handoffIdFactory?: () => string): MosCoordinationClient {
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
}
private identity(): MosCoordinationIdentity {
private identity(): InteractionCoordinationIdentity {
const interactionAgentId = this.config.interactionAgentId?.trim();
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
if (!interactionAgentId) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'unconfigured_requester',
'Interaction agent identity is not configured',
);
}
if (!orchestrationAgentId) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'unconfigured_target',
'Orchestration agent identity is not configured',
);
@@ -177,9 +177,12 @@ export class MosCoordinationService {
});
}
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
if (typeof request !== 'object' || request === null) {
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
throw new InteractionCoordinationGatewayError(
'invalid_request',
'Handoff request is invalid',
);
}
const idempotencyKey = this.requiredString(
request.idempotencyKey,
@@ -203,14 +206,17 @@ export class MosCoordinationService {
private requiredString(value: unknown, field: string, maximumLength: number): string {
if (typeof value !== 'string') {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Mos handoff ${field} must be a string`,
`Handoff ${field} must be a string`,
);
}
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maximumLength) {
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} is invalid`,
);
}
return normalized;
}
@@ -245,23 +251,23 @@ export class MosCoordinationService {
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
const owner = this.owners.get(handoffId);
if (owner === undefined) {
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
}
if (
owner.tenantId !== scope.tenantId ||
owner.actorId !== scope.actorId ||
owner.requesterAgentId !== scope.requesterAgentId
) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'cross_tenant_forbidden',
'Mos handoff is outside the authenticated scope',
'Handoff is outside the authenticated scope',
);
}
return owner;
}
}
export type MosCoordinationGatewayErrorCode =
export type InteractionCoordinationGatewayErrorCode =
| 'cross_tenant_forbidden'
| 'handoff_conflict'
| 'invalid_request'
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
);
}
function sameRequest(
left: NormalizedMosHandoffRequest,
right: NormalizedMosHandoffRequest,
): boolean {
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
return (
left.idempotencyKey === right.idempotencyKey &&
left.summary === right.summary &&
@@ -289,12 +292,12 @@ function sameRequest(
);
}
export class MosCoordinationGatewayError extends Error {
export class InteractionCoordinationGatewayError extends Error {
constructor(
readonly code: MosCoordinationGatewayErrorCode,
readonly code: InteractionCoordinationGatewayErrorCode,
message: string,
) {
super(message);
this.name = MosCoordinationGatewayError.name;
this.name = InteractionCoordinationGatewayError.name;
}
}