234 lines
12 KiB
TypeScript
234 lines
12 KiB
TypeScript
import { hostApiFetch } from '@/lib/host-api';
|
|
import type {
|
|
DataServiceCollectionRemoval,
|
|
DataServiceHostResult,
|
|
DataServiceInstanceRemoval,
|
|
DataServiceInstanceState,
|
|
} from '../../shared/data-service';
|
|
import type { PluginBillingMode } from '../../shared/coding-plugins';
|
|
|
|
export type CodingPluginEffectiveState =
|
|
| 'unavailable' | 'disabled' | 'identity_required' | 'authentication_required'
|
|
| 'configuration_required' | 'ready' | 'degraded';
|
|
export type PluginPolicyStatus = 'current' | 'stale' | 'unavailable';
|
|
|
|
export type PluginBackend =
|
|
| { status: 'not_required' | 'identity_required' | 'authentication_required' | 'unconfigured' | 'ready' }
|
|
| { status: 'degraded'; code: string; message: string; retryable: boolean; retry_after_seconds?: number };
|
|
|
|
export type PluginBilling = {
|
|
mode: PluginBillingMode;
|
|
availability: 'available' | 'unavailable';
|
|
notice: string;
|
|
pricingVersion?: number;
|
|
unitName?: string;
|
|
unitSize?: number;
|
|
ratePoints?: string;
|
|
minimumChargePoints?: string;
|
|
roundingMode?: 'ceil';
|
|
};
|
|
|
|
export type CodingPluginItem = {
|
|
id: string;
|
|
version: string;
|
|
contractVersion: number;
|
|
requiresBackend: boolean;
|
|
displayName: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
state: CodingPluginEffectiveState;
|
|
backend: PluginBackend;
|
|
skills: Array<{ id: string; assignedAgentIds: string[] }>;
|
|
capabilities: Array<{ id: string; operations: Array<{
|
|
id: string;
|
|
billing: PluginBilling;
|
|
tool: {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
mutation: 'read' | 'write' | 'destructive';
|
|
permissions: string[];
|
|
} | null;
|
|
}> }>;
|
|
settingsSurface: string | null;
|
|
};
|
|
|
|
export type CodingPluginProject = {
|
|
schemaVersion: 1;
|
|
project: { localProjectId: string; durableProjectId: string | null };
|
|
policyStatus: PluginPolicyStatus;
|
|
unknownPluginIds: string[];
|
|
items: CodingPluginItem[];
|
|
};
|
|
|
|
export type CodingPluginFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;
|
|
|
|
function record(value: unknown, field: string): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${field} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function exact(value: Record<string, unknown>, keys: readonly string[], field: string): void {
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
throw new Error(`${field} has unexpected fields`);
|
|
}
|
|
}
|
|
|
|
function string(value: unknown, field: string, max = 256): string {
|
|
if (typeof value !== 'string' || !value || value.length > max) throw new Error(`${field} is invalid`);
|
|
return value;
|
|
}
|
|
|
|
function optionalNumber(value: unknown, field: string): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Number.isFinite(value) || (value as number) < 0) throw new Error(`${field} is invalid`);
|
|
return value as number;
|
|
}
|
|
|
|
function backend(value: unknown): PluginBackend {
|
|
const source = record(value, 'backend');
|
|
const status = string(source.status, 'backend.status', 32) as PluginBackend['status'];
|
|
if (['not_required', 'identity_required', 'authentication_required', 'unconfigured', 'ready'].includes(status)) {
|
|
exact(source, ['status'], 'backend');
|
|
return { status } as PluginBackend;
|
|
}
|
|
if (status !== 'degraded') throw new Error('backend.status is invalid');
|
|
const allowed = ['status', 'code', 'message', 'retryable', 'retry_after_seconds'];
|
|
if (Object.keys(source).some((key) => !allowed.includes(key))) throw new Error('backend has unexpected fields');
|
|
if (typeof source.retryable !== 'boolean') throw new Error('backend.retryable is invalid');
|
|
const retryAfter = optionalNumber(source.retry_after_seconds, 'backend.retry_after_seconds');
|
|
return {
|
|
status,
|
|
code: string(source.code, 'backend.code', 64),
|
|
message: string(source.message, 'backend.message', 160),
|
|
retryable: source.retryable,
|
|
...(retryAfter === undefined ? {} : { retry_after_seconds: retryAfter }),
|
|
};
|
|
}
|
|
|
|
function billing(value: unknown): PluginBilling {
|
|
const source = record(value, 'billing');
|
|
const allowed = ['mode', 'availability', 'notice', 'pricingVersion', 'unitName', 'unitSize', 'ratePoints', 'minimumChargePoints', 'roundingMode'];
|
|
if (Object.keys(source).some((key) => !allowed.includes(key))) throw new Error('billing has unexpected fields');
|
|
const mode = string(source.mode, 'billing.mode', 32) as PluginBillingMode;
|
|
if (!['included', 'platform_metered', 'external_account'].includes(mode)) throw new Error('billing.mode is invalid');
|
|
const availability = string(source.availability, 'billing.availability', 16);
|
|
if (availability !== 'available' && availability !== 'unavailable') throw new Error('billing.availability is invalid');
|
|
const result: PluginBilling = { mode, availability, notice: string(source.notice, 'billing.notice', 160) };
|
|
for (const key of ['pricingVersion', 'unitSize'] as const) {
|
|
const parsed = optionalNumber(source[key], `billing.${key}`);
|
|
if (parsed !== undefined) result[key] = parsed;
|
|
}
|
|
for (const key of ['unitName', 'ratePoints', 'minimumChargePoints'] as const) {
|
|
if (source[key] !== undefined) result[key] = string(source[key], `billing.${key}`, 64);
|
|
}
|
|
if (source.roundingMode !== undefined) {
|
|
if (source.roundingMode !== 'ceil') throw new Error('billing.roundingMode is invalid');
|
|
result.roundingMode = 'ceil';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function pluginItem(value: unknown): CodingPluginItem {
|
|
const source = record(value, 'plugin');
|
|
exact(source, ['id', 'version', 'contractVersion', 'requiresBackend', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin');
|
|
if (typeof source.enabled !== 'boolean' || typeof source.requiresBackend !== 'boolean'
|
|
|| !Number.isSafeInteger(source.contractVersion) || (source.contractVersion as number) < 1) {
|
|
throw new Error('plugin metadata is invalid');
|
|
}
|
|
const state = string(source.state, 'plugin.state', 32) as CodingPluginEffectiveState;
|
|
if (!['unavailable', 'disabled', 'identity_required', 'authentication_required', 'configuration_required', 'ready', 'degraded'].includes(state)) throw new Error('plugin.state is invalid');
|
|
if (!Array.isArray(source.skills) || !Array.isArray(source.capabilities)) throw new Error('plugin arrays are invalid');
|
|
return {
|
|
id: string(source.id, 'plugin.id', 128), version: string(source.version, 'plugin.version', 64),
|
|
contractVersion: source.contractVersion as number,
|
|
requiresBackend: source.requiresBackend,
|
|
displayName: string(source.displayName, 'plugin.displayName'), description: string(source.description, 'plugin.description', 512),
|
|
enabled: source.enabled, state, backend: backend(source.backend),
|
|
skills: source.skills.map((value) => {
|
|
const skill = record(value, 'skill'); exact(skill, ['id', 'assignedAgentIds'], 'skill');
|
|
if (!Array.isArray(skill.assignedAgentIds)) throw new Error('skill.assignedAgentIds is invalid');
|
|
return { id: string(skill.id, 'skill.id', 128), assignedAgentIds: skill.assignedAgentIds.map((id) => string(id, 'agent id', 128)) };
|
|
}),
|
|
capabilities: source.capabilities.map((value) => {
|
|
const capability = record(value, 'capability'); exact(capability, ['id', 'operations'], 'capability');
|
|
if (!Array.isArray(capability.operations)) throw new Error('capability.operations is invalid');
|
|
return { id: string(capability.id, 'capability.id', 128), operations: capability.operations.map((value) => {
|
|
const operation = record(value, 'operation'); exact(operation, ['id', 'billing', 'tool'], 'operation');
|
|
let tool: CodingPluginItem['capabilities'][number]['operations'][number]['tool'] = null;
|
|
if (operation.tool !== null) {
|
|
const sourceTool = record(operation.tool, 'operation.tool');
|
|
exact(sourceTool, ['name', 'label', 'description', 'mutation', 'permissions'], 'operation.tool');
|
|
const mutation = string(sourceTool.mutation, 'operation.tool.mutation', 16);
|
|
if (!['read', 'write', 'destructive'].includes(mutation) || !Array.isArray(sourceTool.permissions)) {
|
|
throw new Error('operation.tool is invalid');
|
|
}
|
|
tool = {
|
|
name: string(sourceTool.name, 'operation.tool.name', 64),
|
|
label: string(sourceTool.label, 'operation.tool.label', 128),
|
|
description: string(sourceTool.description, 'operation.tool.description', 512),
|
|
mutation: mutation as 'read' | 'write' | 'destructive',
|
|
permissions: sourceTool.permissions.map((permission) => string(permission, 'operation.tool.permission', 64)),
|
|
};
|
|
}
|
|
return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing), tool };
|
|
}) };
|
|
}),
|
|
settingsSurface: source.settingsSurface === null ? null : string(source.settingsSurface, 'plugin.settingsSurface', 64),
|
|
};
|
|
}
|
|
|
|
export function parseCodingPluginProject(value: unknown): CodingPluginProject {
|
|
const source = record(value, 'plugin project');
|
|
exact(source, ['schemaVersion', 'project', 'policyStatus', 'unknownPluginIds', 'items'], 'plugin project');
|
|
if (source.schemaVersion !== 1 || !Array.isArray(source.items) || !Array.isArray(source.unknownPluginIds)) throw new Error('plugin project schema is invalid');
|
|
const project = record(source.project, 'project'); exact(project, ['localProjectId', 'durableProjectId'], 'project');
|
|
const policyStatus = string(source.policyStatus, 'policyStatus', 16) as PluginPolicyStatus;
|
|
if (!['current', 'stale', 'unavailable'].includes(policyStatus)) throw new Error('policyStatus is invalid');
|
|
return {
|
|
schemaVersion: 1,
|
|
project: { localProjectId: string(project.localProjectId, 'project.localProjectId', 128), durableProjectId: project.durableProjectId === null ? null : string(project.durableProjectId, 'project.durableProjectId', 128) },
|
|
policyStatus,
|
|
unknownPluginIds: source.unknownPluginIds.map((id) => string(id, 'unknown plugin id', 128)),
|
|
items: source.items.map(pluginItem),
|
|
};
|
|
}
|
|
|
|
const defaultFetch: CodingPluginFetcher = hostApiFetch;
|
|
|
|
export async function getCodingPlugins(projectId: string, fetcher = defaultFetch): Promise<CodingPluginProject> {
|
|
return parseCodingPluginProject(await fetcher(`/api/coding/plugins?projectId=${encodeURIComponent(projectId)}`));
|
|
}
|
|
|
|
export async function setCodingPluginEnabled(projectId: string, pluginId: string, enabled: boolean, fetcher = defaultFetch): Promise<CodingPluginProject> {
|
|
return parseCodingPluginProject(await fetcher(`/api/coding/plugins/${encodeURIComponent(pluginId)}`, {
|
|
method: 'PUT', body: JSON.stringify({ projectId, enabled }),
|
|
}));
|
|
}
|
|
|
|
function dataServiceResult<T>(value: unknown): DataServiceHostResult<T> {
|
|
const source = record(value, 'Data Service result');
|
|
const allowed = ['success', 'status', 'code', 'error', 'retryable', 'retry_after_seconds', 'context', 'data'];
|
|
if (Object.keys(source).some((key) => !allowed.includes(key)) || typeof source.success !== 'boolean'
|
|
|| !Number.isSafeInteger(source.status) || typeof source.retryable !== 'boolean') throw new Error('Data Service result is invalid');
|
|
return value as DataServiceHostResult<T>;
|
|
}
|
|
|
|
export async function inspectDataService(fetcher = defaultFetch) {
|
|
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project'));
|
|
}
|
|
export async function configureDataService(collections: string[], fetcher = defaultFetch) {
|
|
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project', { method: 'PUT', body: JSON.stringify({ collections }) }));
|
|
}
|
|
export async function resetDataService(fetcher = defaultFetch) {
|
|
return dataServiceResult<DataServiceInstanceState>(await fetcher('/api/works/data-service/project/reset?confirmed=true', { method: 'POST' }));
|
|
}
|
|
export async function removeDataServiceCollection(collection: string, fetcher = defaultFetch) {
|
|
return dataServiceResult<DataServiceCollectionRemoval>(await fetcher(`/api/works/data-service/project/collections/${encodeURIComponent(collection)}?confirmed=true`, { method: 'DELETE' }));
|
|
}
|
|
export async function removeDataServiceProject(fetcher = defaultFetch) {
|
|
return dataServiceResult<DataServiceInstanceRemoval>(await fetcher('/api/works/data-service/project?confirmed=true', { method: 'DELETE' }));
|
|
}
|