307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
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);
|