feat(tess): add generic interaction CLI (#731)
This commit was merged in pull request #731.
This commit is contained in:
192
apps/gateway/src/agent/interaction.controller.ts
Normal file
192
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import type { RuntimeAttachMode } from '@mosaicstack/types';
|
||||
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 {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
||||
* selected from deployment configuration, never a client-side command name.
|
||||
*/
|
||||
@Controller('api/interaction/:agentName')
|
||||
@UseGuards(AuthGuard)
|
||||
export class InteractionController {
|
||||
constructor(
|
||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
||||
) {}
|
||||
|
||||
@Get('sessions')
|
||||
async sessions(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.listSessions(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
async tree(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.getSessionTree(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/attach')
|
||||
async attach(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { mode?: RuntimeAttachMode } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const mode = body.mode ?? 'read';
|
||||
if (mode !== 'read' && mode !== 'control') {
|
||||
throw new ForbiddenException('Interaction attach mode is invalid');
|
||||
}
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
return this.runtime.attach(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
mode,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/send')
|
||||
async send(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
||||
throw new ForbiddenException('Content and idempotency key are required');
|
||||
}
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
const input = {
|
||||
sessionId,
|
||||
content: body.content,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
};
|
||||
await this.durable.queueProviderSend(input);
|
||||
await this.durable.dispatchProviderOutbox(sessionId, input);
|
||||
return { status: 'queued', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/stop')
|
||||
async stop(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { approvalRef?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.approvalRef?.trim())
|
||||
throw new ForbiddenException('Exact-action approval is required');
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.runtime.terminate(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
body.approvalRef,
|
||||
context,
|
||||
);
|
||||
return { status: 'stopped', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/recover')
|
||||
async recover(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.durable.recoverProviderSession(sessionId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: `recovery:${context.correlationId}`,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return { status: 'recovered', sessionId };
|
||||
}
|
||||
|
||||
private context(
|
||||
user: AuthenticatedUserLike,
|
||||
correlationId?: string,
|
||||
): RuntimeProviderRequestContext {
|
||||
const requestCorrelationId = correlationId?.trim();
|
||||
// This non-simple request header is mandatory for mutations. Browser
|
||||
// cross-origin requests cannot set it without a CORS preflight, and the
|
||||
// gateway's allowlist rejects untrusted origins before the handler runs.
|
||||
if (!requestCorrelationId) {
|
||||
throw new ForbiddenException('X-Correlation-Id is required');
|
||||
}
|
||||
return {
|
||||
actorScope: scopeFromUser(user),
|
||||
channelId: 'cli',
|
||||
correlationId: requestCorrelationId,
|
||||
};
|
||||
}
|
||||
|
||||
private assertConfiguredAgent(agentName: string): void {
|
||||
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!configured || configured !== agentName) {
|
||||
throw new ForbiddenException('Interaction agent is not configured for this request');
|
||||
}
|
||||
}
|
||||
|
||||
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
||||
if (sessionAgentName !== agentName)
|
||||
throw new ForbiddenException('Interaction session identity mismatch');
|
||||
}
|
||||
|
||||
private requiredProvider(providerId: string): string {
|
||||
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user