feat(tess): add generic interaction CLI
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -9,6 +9,7 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
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';
|
||||
@@ -54,7 +55,13 @@ import {
|
||||
RuntimeProviderService,
|
||||
AgentService,
|
||||
],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||
controllers: [
|
||||
ProvidersController,
|
||||
SessionsController,
|
||||
AgentConfigsController,
|
||||
InteractionController,
|
||||
RoutingController,
|
||||
],
|
||||
exports: [
|
||||
AgentService,
|
||||
ProviderService,
|
||||
|
||||
70
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
70
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
|
||||
describe('InteractionController', (): void => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('honors a differently named configured instance without a code change', async () => {
|
||||
const prior = process.env['MOSAIC_AGENT_NAME'];
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
||||
const controller = new InteractionController(runtime as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction agent is not configured');
|
||||
|
||||
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||
});
|
||||
|
||||
it('rejects a request without the non-simple correlation header', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
||||
'X-Correlation-Id is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid attach mode before invoking a provider', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction attach mode is invalid');
|
||||
});
|
||||
|
||||
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
);
|
||||
|
||||
expect(runtime.terminate).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
'approval-1',
|
||||
expect.objectContaining({
|
||||
correlationId: 'corr-1',
|
||||
actorScope: { userId: 'owner', tenantId: 'owner' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { DurableSessionCoordinator } from '@mosaicstack/agent';
|
||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { RuntimeProviderService } from './runtime-provider-registry.service.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
||||
@@ -61,6 +64,19 @@ export class TessDurableSessionService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
||||
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: 'read-only',
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
|
||||
Reference in New Issue
Block a user