feat: add project plugin center
This commit is contained in:
198
src/lib/coding-plugins.ts
Normal file
198
src/lib/coding-plugins.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
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;
|
||||
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 }> }>;
|
||||
settingsSurface: string | null;
|
||||
};
|
||||
|
||||
export type CodingPluginProject = {
|
||||
schemaVersion: 1;
|
||||
project: { localProjectId: string; durableProjectId: string | null };
|
||||
policyStatus: PluginPolicyStatus;
|
||||
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', 'displayName', 'description', 'enabled', 'state', 'backend', 'skills', 'capabilities', 'settingsSurface'], 'plugin');
|
||||
if (typeof source.enabled !== 'boolean') throw new Error('plugin.enabled 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),
|
||||
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'], 'operation');
|
||||
return { id: string(operation.id, 'operation.id', 128), billing: billing(operation.billing) };
|
||||
}) };
|
||||
}),
|
||||
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', 'items'], 'plugin project');
|
||||
if (source.schemaVersion !== 1 || !Array.isArray(source.items)) 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,
|
||||
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' }));
|
||||
}
|
||||
Reference in New Issue
Block a user