Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3: GET /api/harnesses, GET /api/harnesses/:harnessId/catalog, GET+PUT /api/chat/preferences/selection. Scope is always server-derived via scopeFromUser(CurrentUser); selection tuples are validated against the live catalog with no fallback substitution and persisted in a transitional owner-scoped in-memory store. HarnessModule is wired into AppModule. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { HarnessSelection } from '@mosaicstack/types';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import {
|
||||
HarnessAdapterUnavailableError,
|
||||
HarnessRegistry,
|
||||
operationError,
|
||||
} from './harness.registry.js';
|
||||
import { HARNESS_REGISTRY } from './harness.tokens.js';
|
||||
import { readContextFromScope } from './harness.dto.js';
|
||||
import { HarnessSelectionRepository } from './harness-selection.repository.js';
|
||||
|
||||
/**
|
||||
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
|
||||
* requested harness/provider/model tuple against the live catalog with NO
|
||||
* fallback substitution, then persists it owner-scoped. The stored selection is
|
||||
* only ever mutated when the tuple is valid AND available.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HarnessSelectionService {
|
||||
constructor(
|
||||
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
|
||||
private readonly repository: HarnessSelectionRepository,
|
||||
) {}
|
||||
|
||||
getSelection(scope: ActorTenantScope): HarnessSelection | null {
|
||||
return this.repository.get(scope);
|
||||
}
|
||||
|
||||
async setSelection(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<HarnessSelection> {
|
||||
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
|
||||
// requested tuple echoed back unchanged. The store is untouched on any throw.
|
||||
await this.assertSelectionAvailable(scope, selection);
|
||||
return this.repository.set(scope, selection);
|
||||
}
|
||||
|
||||
private async assertSelectionAvailable(
|
||||
scope: ActorTenantScope,
|
||||
selection: HarnessSelection,
|
||||
): Promise<void> {
|
||||
const correlationId = randomUUID();
|
||||
|
||||
let adapter;
|
||||
try {
|
||||
adapter = this.registry.get(selection.harnessId);
|
||||
} catch (error) {
|
||||
if (error instanceof HarnessAdapterUnavailableError) {
|
||||
// An unknown harness makes the whole tuple invalid — no fallback adapter.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const catalog = await adapter.catalog(readContextFromScope(scope));
|
||||
const entry = catalog.models.find(
|
||||
(candidate) =>
|
||||
candidate.harnessId === selection.harnessId &&
|
||||
candidate.providerId === selection.providerId &&
|
||||
candidate.modelId === selection.modelId,
|
||||
);
|
||||
|
||||
if (!entry) {
|
||||
// No first-row / first-provider fallback: reject the requested tuple unchanged.
|
||||
throw operationError(
|
||||
'selection_invalid',
|
||||
'The requested harness/provider/model tuple is not in the catalog.',
|
||||
selection,
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
if (entry.availability === 'unavailable') {
|
||||
throw operationError(
|
||||
'model_unavailable',
|
||||
'The requested model is currently unavailable.',
|
||||
selection,
|
||||
correlationId,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user