feat(coding): add plugin capability policy registry
This commit is contained in:
306
electron/coding-plugins/adapters/data-service.ts
Normal file
306
electron/coding-plugins/adapters/data-service.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import type {
|
||||
DataServiceCollectionRemoval,
|
||||
DataServiceDocument,
|
||||
DataServiceDocumentList,
|
||||
DataServiceErrorContext,
|
||||
DataServiceHostResult,
|
||||
DataServiceInstanceList,
|
||||
DataServiceInstanceRemoval,
|
||||
DataServiceInstanceState,
|
||||
} from '../../../shared/data-service';
|
||||
import {
|
||||
DATA_SERVICE_PLUGIN_ID,
|
||||
DATA_SERVICE_TOOL_DEFINITIONS,
|
||||
type CodingPluginToolDefinition,
|
||||
} from '../../../shared/coding-plugins';
|
||||
import type { DataServiceOperations } from '../../services/data-service-client';
|
||||
import type {
|
||||
AdapterInvocationResult,
|
||||
CodingPluginAdapter,
|
||||
PluginBackendProjection,
|
||||
TrustedCodingCapabilityContext,
|
||||
} from '../registry';
|
||||
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const CONTEXT_KEYS = new Set([
|
||||
'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision',
|
||||
]);
|
||||
const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']);
|
||||
|
||||
type InputRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is InputRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedContext(value: DataServiceErrorContext | undefined): DataServiceErrorContext | undefined {
|
||||
if (!value) return undefined;
|
||||
const projected: DataServiceErrorContext = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (!CONTEXT_KEYS.has(key)) continue;
|
||||
if (key === 'resource') {
|
||||
if (typeof item !== 'string' || !CONTEXT_RESOURCES.has(item) || item.length > 32) return undefined;
|
||||
projected[key] = item;
|
||||
} else if (Number.isSafeInteger(item) && (item as number) >= 0) {
|
||||
projected[key] = item as number;
|
||||
}
|
||||
}
|
||||
return Object.keys(projected).length > 0 ? projected : undefined;
|
||||
}
|
||||
|
||||
function validRetryAfter(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 86_400;
|
||||
}
|
||||
|
||||
function boundedError(error: unknown, fallback: string): string {
|
||||
return typeof error === 'string' && error.trim().length > 0 && error.length <= 2_000
|
||||
? error
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: string,
|
||||
error: string,
|
||||
status = 422,
|
||||
retryable = false,
|
||||
): AdapterInvocationResult {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function projectResult<T>(result: DataServiceHostResult<T>): AdapterInvocationResult<T> {
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
status: result.status,
|
||||
code: null,
|
||||
error: null,
|
||||
retryable: false,
|
||||
payload_schema: 'data-service.v1',
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
const context = boundedContext(result.context);
|
||||
return {
|
||||
success: false,
|
||||
status: result.status,
|
||||
code: result.code ?? 'plugin_backend_unavailable',
|
||||
error: boundedError(result.error, 'Data Service request was rejected'),
|
||||
retryable: result.retryable,
|
||||
...(validRetryAfter(result.retry_after_seconds)
|
||||
? { retry_after_seconds: result.retry_after_seconds }
|
||||
: {}),
|
||||
...(context ? { context } : {}),
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
function schemaValid(value: unknown, schema: Readonly<Record<string, unknown>>): boolean {
|
||||
if (schema.type === 'object') {
|
||||
if (!isRecord(value)) return false;
|
||||
const properties = isRecord(schema.properties) ? schema.properties : {};
|
||||
if (schema.additionalProperties === false
|
||||
&& Object.keys(value).some((key) => !Object.prototype.hasOwnProperty.call(properties, key))) {
|
||||
return false;
|
||||
}
|
||||
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||
if (required.some((key) => typeof key !== 'string' || !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(value).every(([key, item]) => {
|
||||
const child = properties[key];
|
||||
return !child || (isRecord(child) && schemaValid(item, child));
|
||||
});
|
||||
}
|
||||
if (schema.type === 'array') {
|
||||
if (!Array.isArray(value)) return false;
|
||||
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) return false;
|
||||
const items = isRecord(schema.items) ? schema.items : null;
|
||||
return !items || value.every((item) => schemaValid(item, items));
|
||||
}
|
||||
if (schema.type === 'string') {
|
||||
if (typeof value !== 'string') return false;
|
||||
if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false;
|
||||
if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false;
|
||||
if (typeof schema.pattern === 'string' && !(new RegExp(schema.pattern, 'u')).test(value)) return false;
|
||||
return true;
|
||||
}
|
||||
if (schema.type === 'integer') {
|
||||
if (!Number.isSafeInteger(value)) return false;
|
||||
if (typeof schema.minimum === 'number' && (value as number) < schema.minimum) return false;
|
||||
if (typeof schema.maximum === 'number' && (value as number) > schema.maximum) return false;
|
||||
return true;
|
||||
}
|
||||
if (schema.type === 'boolean') return typeof value === 'boolean'
|
||||
&& (schema.const === undefined || value === schema.const);
|
||||
return true;
|
||||
}
|
||||
|
||||
function boundedDocumentData(value: unknown): boolean {
|
||||
if (!isRecord(value)) return false;
|
||||
try {
|
||||
const encoded = JSON.stringify({ data: value });
|
||||
return typeof encoded === 'string' && Buffer.byteLength(encoded, 'utf8') <= MAX_REQUEST_BYTES;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateInput(tool: CodingPluginToolDefinition, input: unknown): boolean {
|
||||
if (!schemaValid(input, tool.inputSchema)) return false;
|
||||
if (!isRecord(input)) return false;
|
||||
if (tool.name === 'data_service_put_document' && !boundedDocumentData(input.data)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type DataServiceResult =
|
||||
| DataServiceInstanceState
|
||||
| DataServiceInstanceList
|
||||
| DataServiceDocument
|
||||
| DataServiceDocumentList
|
||||
| DataServiceCollectionRemoval
|
||||
| DataServiceInstanceRemoval
|
||||
| null;
|
||||
|
||||
function toolByName(toolName: string): CodingPluginToolDefinition | undefined {
|
||||
return DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === toolName);
|
||||
}
|
||||
|
||||
async function callDataService(
|
||||
operations: DataServiceOperations,
|
||||
toolName: string,
|
||||
input: InputRecord,
|
||||
projectPath: string,
|
||||
): Promise<DataServiceHostResult<DataServiceResult>> {
|
||||
switch (toolName) {
|
||||
case 'data_service_configure':
|
||||
return await operations.configure({ collections: input.collections as string[] }, projectPath);
|
||||
case 'data_service_inspect':
|
||||
return await operations.inspect(projectPath);
|
||||
case 'data_service_list_projects':
|
||||
return await operations.listProjects();
|
||||
case 'data_service_get_document':
|
||||
return await operations.getDocument({
|
||||
collection: input.collection as string,
|
||||
document_id: input.document_id as string,
|
||||
}, projectPath);
|
||||
case 'data_service_list_documents':
|
||||
return await operations.listDocuments({
|
||||
collection: input.collection as string,
|
||||
...(input.limit === undefined ? {} : { limit: input.limit as number }),
|
||||
...(input.cursor === undefined ? {} : { cursor: input.cursor as string }),
|
||||
}, projectPath);
|
||||
case 'data_service_put_document':
|
||||
return await operations.putDocument({
|
||||
collection: input.collection as string,
|
||||
document_id: input.document_id as string,
|
||||
data: input.data as Record<string, unknown>,
|
||||
...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }),
|
||||
}, projectPath);
|
||||
case 'data_service_delete_document':
|
||||
return await operations.deleteDocument({
|
||||
collection: input.collection as string,
|
||||
document_id: input.document_id as string,
|
||||
...(input.if_revision === undefined ? {} : { if_revision: input.if_revision as number }),
|
||||
confirmed: true,
|
||||
}, projectPath);
|
||||
case 'data_service_remove_collection':
|
||||
return await operations.removeCollection({
|
||||
collection: input.collection as string,
|
||||
confirmed: true,
|
||||
}, projectPath);
|
||||
case 'data_service_reset':
|
||||
return await operations.reset({ confirmed: true }, projectPath);
|
||||
case 'data_service_remove_project':
|
||||
return await operations.removeProject({ confirmed: true }, projectPath);
|
||||
default:
|
||||
throw new Error('Data Service tool is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
function projection(result: DataServiceHostResult<unknown>): PluginBackendProjection {
|
||||
if (result.success) return { status: 'ready' };
|
||||
switch (result.code) {
|
||||
case 'authentication_required': return { status: 'authentication_required' };
|
||||
case 'project_identity_required': return { status: 'identity_required' };
|
||||
case 'active_project_required':
|
||||
case 'instance_not_found': return { status: 'unconfigured' };
|
||||
case 'active_project_path_mismatch':
|
||||
case 'active_project_invalid': return { status: 'degraded', code: result.code, message: 'Data Service project is unavailable', retryable: false };
|
||||
default:
|
||||
return {
|
||||
status: 'degraded',
|
||||
code: result.code ?? 'plugin_backend_unavailable',
|
||||
message: 'Data Service is temporarily unavailable',
|
||||
retryable: result.retryable,
|
||||
...(validRetryAfter(result.retry_after_seconds)
|
||||
? { retry_after_seconds: result.retry_after_seconds }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DataServicePluginAdapter implements CodingPluginAdapter {
|
||||
readonly pluginId = DATA_SERVICE_PLUGIN_ID;
|
||||
|
||||
constructor(private readonly operations: DataServiceOperations) {}
|
||||
|
||||
async inspect(projectPath: string): Promise<PluginBackendProjection> {
|
||||
try {
|
||||
return projection(await this.operations.inspect(projectPath));
|
||||
} catch {
|
||||
return {
|
||||
status: 'degraded',
|
||||
code: 'plugin_backend_unavailable',
|
||||
message: 'Data Service is temporarily unavailable',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async invoke(
|
||||
_context: TrustedCodingCapabilityContext,
|
||||
tool: CodingPluginToolDefinition,
|
||||
input: unknown,
|
||||
): Promise<AdapterInvocationResult<DataServiceResult>> {
|
||||
const canonicalTool = toolByName(tool.name);
|
||||
if (!canonicalTool || !validateInput(canonicalTool, input)) {
|
||||
return failure('plugin_input_invalid', 'Data Service tool input is invalid');
|
||||
}
|
||||
try {
|
||||
return projectResult(await callDataService(
|
||||
this.operations,
|
||||
canonicalTool.name,
|
||||
input as InputRecord,
|
||||
_context.projectPath,
|
||||
));
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
status: 503,
|
||||
code: 'plugin_backend_unavailable',
|
||||
error: 'Data Service is temporarily unavailable',
|
||||
retryable: true,
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deactivate(_projectPath: string): Promise<void> {
|
||||
// Data Service deactivation invalidates preview sessions in the dedicated
|
||||
// lifecycle owner; this adapter owns no independent process or token.
|
||||
}
|
||||
}
|
||||
|
||||
export const createDataServicePluginAdapter = (
|
||||
operations: DataServiceOperations,
|
||||
): DataServicePluginAdapter => new DataServicePluginAdapter(operations);
|
||||
Reference in New Issue
Block a user