286 lines
8.5 KiB
TypeScript
286 lines
8.5 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import type {
|
|
HarnessActorContext,
|
|
HarnessAdapter,
|
|
HarnessCatalog,
|
|
HarnessCloseReason,
|
|
HarnessInteractionResponse,
|
|
HarnessSelection,
|
|
HarnessSessionHandle,
|
|
HarnessSessionSnapshot,
|
|
} from '@mosaicstack/types';
|
|
import {
|
|
HarnessAdapterUnavailableError,
|
|
HarnessRegistry,
|
|
operationError,
|
|
} from './harness.registry.js';
|
|
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
|
|
|
/**
|
|
* Trusted, server-derived authority. In production this is produced by the
|
|
* Gateway from the authenticated session — never from a browser/caller DTO.
|
|
*/
|
|
export interface TrustedGatewayScope {
|
|
readonly actorId: string;
|
|
readonly tenantId: string;
|
|
readonly seatId: string;
|
|
readonly correlationId: string;
|
|
}
|
|
|
|
/** Server-authority fields that must never arrive from an external request DTO. */
|
|
const FORBIDDEN_REQUEST_FIELDS = [
|
|
'actorId',
|
|
'tenantId',
|
|
'correlationId',
|
|
'seatId',
|
|
'seat',
|
|
'executable',
|
|
'executablePath',
|
|
'home',
|
|
'homeDir',
|
|
'cwd',
|
|
'workingDir',
|
|
'workingDirectory',
|
|
'nativeSessionPath',
|
|
'sessionPath',
|
|
] as const;
|
|
|
|
/** Raised when an external request DTO smuggles a server-authority field. */
|
|
export class HarnessScopeViolationError extends Error {
|
|
constructor(readonly field: string) {
|
|
super(`External request supplied server-authority field "${field}".`);
|
|
this.name = 'HarnessScopeViolationError';
|
|
}
|
|
}
|
|
|
|
export interface CreateHarnessSessionRequest {
|
|
readonly conversationId: string;
|
|
readonly selection: HarnessSelection;
|
|
}
|
|
|
|
export interface ResumeHarnessSessionRequest {
|
|
readonly conversationId: string;
|
|
readonly nativeSessionId: string;
|
|
readonly selection: HarnessSelection;
|
|
}
|
|
|
|
export interface AttachClientRequest {
|
|
readonly conversationId: string;
|
|
readonly clientId: string;
|
|
}
|
|
|
|
export interface DetachClientRequest {
|
|
readonly conversationId: string;
|
|
readonly clientId: string;
|
|
}
|
|
|
|
export interface EvictSessionRequest {
|
|
readonly conversationId: string;
|
|
readonly reason: HarnessCloseReason;
|
|
}
|
|
|
|
export interface EndSessionRequest {
|
|
readonly conversationId: string;
|
|
readonly reason: HarnessCloseReason;
|
|
}
|
|
|
|
export interface RespondInteractionRequest {
|
|
readonly conversationId: string;
|
|
readonly response: HarnessInteractionResponse;
|
|
}
|
|
|
|
interface ActiveSession {
|
|
readonly harnessId: string;
|
|
readonly handle: HarnessSessionHandle;
|
|
readonly correlationId: string;
|
|
}
|
|
|
|
/**
|
|
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
|
|
* from trusted Gateway scope, validates the selected provider/model tuple with
|
|
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
|
|
* lifecycle operations.
|
|
*/
|
|
@Injectable()
|
|
export class HarnessService {
|
|
private readonly sessions = new Map<string, ActiveSession>();
|
|
|
|
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
|
|
|
|
async createSession(
|
|
scope: TrustedGatewayScope,
|
|
request: CreateHarnessSessionRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const { conversationId, selection } = request;
|
|
const adapter = this.resolveAdapter(scope, selection);
|
|
const context = deriveActorContext(scope);
|
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
|
|
|
const handle = await adapter.create({ context, conversationId, selection });
|
|
this.sessions.set(conversationId, {
|
|
harnessId: selection.harnessId,
|
|
handle,
|
|
correlationId: scope.correlationId,
|
|
});
|
|
return handle.snapshot();
|
|
}
|
|
|
|
async resumeSession(
|
|
scope: TrustedGatewayScope,
|
|
request: ResumeHarnessSessionRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const { conversationId, nativeSessionId, selection } = request;
|
|
const adapter = this.resolveAdapter(scope, selection);
|
|
const context = deriveActorContext(scope);
|
|
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
|
|
|
|
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
|
|
this.sessions.set(conversationId, {
|
|
harnessId: selection.harnessId,
|
|
handle,
|
|
correlationId: scope.correlationId,
|
|
});
|
|
return handle.snapshot();
|
|
}
|
|
|
|
async attach(
|
|
scope: TrustedGatewayScope,
|
|
request: AttachClientRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const handle = this.requireHandle(scope, request.conversationId);
|
|
await handle.attach({ clientId: request.clientId });
|
|
return handle.snapshot();
|
|
}
|
|
|
|
async detach(
|
|
scope: TrustedGatewayScope,
|
|
request: DetachClientRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const handle = this.requireHandle(scope, request.conversationId);
|
|
await handle.detach(request.clientId);
|
|
return handle.snapshot();
|
|
}
|
|
|
|
async evict(
|
|
scope: TrustedGatewayScope,
|
|
request: EvictSessionRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const handle = this.requireHandle(scope, request.conversationId);
|
|
await handle.evictProcess(request.reason);
|
|
return handle.snapshot();
|
|
}
|
|
|
|
async end(
|
|
scope: TrustedGatewayScope,
|
|
request: EndSessionRequest,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
assertTrustedRequest(request);
|
|
const handle = this.requireHandle(scope, request.conversationId);
|
|
await handle.endSession(request.reason);
|
|
const snapshot = await handle.snapshot();
|
|
this.sessions.delete(request.conversationId);
|
|
return snapshot;
|
|
}
|
|
|
|
async respondInteraction(
|
|
scope: TrustedGatewayScope,
|
|
request: RespondInteractionRequest,
|
|
): Promise<void> {
|
|
assertTrustedRequest(request);
|
|
const handle = this.requireHandle(scope, request.conversationId);
|
|
await handle.respondInteraction(request.response);
|
|
}
|
|
|
|
async snapshot(
|
|
scope: TrustedGatewayScope,
|
|
conversationId: string,
|
|
): Promise<HarnessSessionSnapshot> {
|
|
const handle = this.requireHandle(scope, conversationId);
|
|
return handle.snapshot();
|
|
}
|
|
|
|
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
|
|
try {
|
|
return this.registry.get(selection.harnessId);
|
|
} catch (error) {
|
|
if (error instanceof HarnessAdapterUnavailableError) {
|
|
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async assertSelectionAvailable(
|
|
scope: TrustedGatewayScope,
|
|
catalogPromise: Promise<HarnessCatalog>,
|
|
selection: HarnessSelection,
|
|
): Promise<void> {
|
|
const catalog = await catalogPromise;
|
|
const entry = catalog.models.find(
|
|
(candidate) =>
|
|
candidate.harnessId === selection.harnessId &&
|
|
candidate.providerId === selection.providerId &&
|
|
candidate.modelId === selection.modelId,
|
|
);
|
|
if (!entry) {
|
|
// No first-row fallback: reject the requested tuple unchanged.
|
|
throw operationError(
|
|
'selection_invalid',
|
|
'The requested harness/provider/model tuple is not in the catalog.',
|
|
selection,
|
|
scope.correlationId,
|
|
);
|
|
}
|
|
if (entry.availability === 'unavailable') {
|
|
throw operationError(
|
|
'model_unavailable',
|
|
'The requested model is currently unavailable.',
|
|
selection,
|
|
scope.correlationId,
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
|
|
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
|
|
const active = this.sessions.get(conversationId);
|
|
if (!active) {
|
|
throw operationError(
|
|
'session_not_found',
|
|
`No active harness session for conversation "${conversationId}".`,
|
|
{ harnessId: '', providerId: '', modelId: '' },
|
|
scope.correlationId,
|
|
);
|
|
}
|
|
return active.handle;
|
|
}
|
|
}
|
|
|
|
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
|
|
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
|
|
return {
|
|
actorId: scope.actorId,
|
|
tenantId: scope.tenantId,
|
|
seatId: scope.seatId,
|
|
correlationId: scope.correlationId,
|
|
};
|
|
}
|
|
|
|
/** Reject any request object that carries a server-authority field. */
|
|
function assertTrustedRequest(request: object): void {
|
|
for (const field of FORBIDDEN_REQUEST_FIELDS) {
|
|
if (Object.prototype.hasOwnProperty.call(request, field)) {
|
|
throw new HarnessScopeViolationError(field);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-export the typed operation error so callers importing from the service
|
|
// have the discriminated failure type without reaching into the registry.
|
|
export { HarnessOperationError } from './harness.registry.js';
|