ci/woodpecker/pr/ci Pipeline failed
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
91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|
|
}
|