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);
|
||||
549
electron/coding-plugins/registry.ts
Normal file
549
electron/coding-plugins/registry.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import {
|
||||
BUNDLED_CODING_PLUGIN_DEFINITIONS,
|
||||
type CodingPluginDefinition,
|
||||
type CodingPluginToolDefinition,
|
||||
type PluginBillingMode,
|
||||
} from '../../shared/coding-plugins';
|
||||
import { CORE_CODING_SKILL_IDS } from '../../shared/coding-skills';
|
||||
import type {
|
||||
CapabilityBillingReceiptV1,
|
||||
CapabilityResultV1,
|
||||
} from '../../shared/data-service';
|
||||
import type { PiProductToolContext, PiProductToolResult } from '../coding-runtime/pi/product-tools';
|
||||
import type {
|
||||
PluginCatalog,
|
||||
PluginCatalogOperation,
|
||||
PluginPolicyClientState,
|
||||
} from '../services/plugin-policy-client';
|
||||
|
||||
const MAX_REQUEST_ID = 128;
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u;
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
|
||||
const OPERATION_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$/u;
|
||||
const DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
|
||||
const CONTEXT_KEYS = new Set([
|
||||
'resource', 'limit', 'current', 'attempted', 'actual', 'current_revision',
|
||||
]);
|
||||
const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']);
|
||||
|
||||
export interface TrustedCodingCapabilityContext {
|
||||
conversationId: string;
|
||||
runId: string;
|
||||
resourceId: string;
|
||||
requestId: string;
|
||||
localProjectId: string;
|
||||
projectPath: string;
|
||||
durableProjectId: string;
|
||||
workerRole: 'parent' | 'child';
|
||||
effectiveSkillIds: readonly string[];
|
||||
}
|
||||
|
||||
export type PluginBackendProjection =
|
||||
| { status: 'not_required' }
|
||||
| { status: 'identity_required' }
|
||||
| { status: 'authentication_required' }
|
||||
| { status: 'unconfigured' }
|
||||
| { status: 'ready' }
|
||||
| {
|
||||
status: 'degraded';
|
||||
code: string;
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
retry_after_seconds?: number;
|
||||
};
|
||||
|
||||
export type AdapterInvocationResult<T = unknown> =
|
||||
| {
|
||||
success: true;
|
||||
status: number;
|
||||
code: null;
|
||||
error: null;
|
||||
retryable: false;
|
||||
payload_schema: string;
|
||||
data: T | null;
|
||||
billing?: CapabilityBillingReceiptV1;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
status: number;
|
||||
code: string;
|
||||
error: string;
|
||||
retryable: boolean;
|
||||
retry_after_seconds?: number;
|
||||
context?: Readonly<Record<string, string | number>>;
|
||||
payload_schema: string;
|
||||
data: null;
|
||||
billing?: CapabilityBillingReceiptV1;
|
||||
};
|
||||
|
||||
export interface CodingPluginAdapter {
|
||||
readonly pluginId: string;
|
||||
inspect(projectPath: string): Promise<PluginBackendProjection>;
|
||||
invoke(
|
||||
context: TrustedCodingCapabilityContext,
|
||||
tool: CodingPluginToolDefinition,
|
||||
input: unknown,
|
||||
): Promise<AdapterInvocationResult>;
|
||||
deactivate?(projectPath: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ResolvedWorkerResources {
|
||||
catalogRevision: number;
|
||||
pluginIds: readonly string[];
|
||||
effectiveSkillIds: readonly string[];
|
||||
skillEntries: readonly { id: string; entryPath: string }[];
|
||||
tools: readonly CodingPluginToolDefinition[];
|
||||
}
|
||||
|
||||
export interface CodingCapabilityRegistryPort {
|
||||
resolveWorkerResources(input: {
|
||||
projectPath: string;
|
||||
assignedSkillIds: readonly string[];
|
||||
role: 'parent' | 'child';
|
||||
}): Promise<ResolvedWorkerResources>;
|
||||
invoke(input: {
|
||||
toolName: string;
|
||||
context: PiProductToolContext;
|
||||
workerRole: 'parent' | 'child';
|
||||
effectiveSkillIds: readonly string[];
|
||||
value: unknown;
|
||||
}): Promise<PiProductToolResult>;
|
||||
}
|
||||
|
||||
export type CodingCapabilityRegistry = CodingCapabilityRegistryPort;
|
||||
|
||||
export interface CodingCapabilityRegistryOptions {
|
||||
policyClient: Pick<{ getState(): PluginPolicyClientState }, 'getState'>;
|
||||
getEnabledPluginIds?: (projectPath: string) => Promise<readonly string[]>;
|
||||
projectPlugins?: {
|
||||
getEnabledPluginIds(projectPath: string): Promise<readonly string[]>;
|
||||
};
|
||||
adapters: readonly CodingPluginAdapter[];
|
||||
definitions?: readonly CodingPluginDefinition[];
|
||||
getDurableProjectId?: (projectPath: string, localProjectId: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedText(value: unknown, maximum: number, fallback: string): string {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function validRequestPart(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= MAX_REQUEST_ID;
|
||||
}
|
||||
|
||||
function requestId(context: PiProductToolContext): string {
|
||||
const runId = context.runId;
|
||||
const resourceId = context.resourceId;
|
||||
const result = `pi:${runId}:${resourceId}`;
|
||||
return validRequestPart(runId) && validRequestPart(resourceId) && result.length <= MAX_REQUEST_ID
|
||||
? result
|
||||
: 'invalid-request-id';
|
||||
}
|
||||
|
||||
function boundedStatus(value: unknown, fallback: number): number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 100 && (value as number) <= 599
|
||||
? value as number
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function boundedRetry(value: unknown): number | undefined {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= 86_400
|
||||
? value as number
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function boundedContext(value: unknown): Readonly<Record<string, string | number>> | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const result: Record<string, string | number> = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
if (!CONTEXT_KEYS.has(key)) continue;
|
||||
if (key === 'resource') {
|
||||
if (typeof item !== 'string' || item.length === 0 || item.length > 32
|
||||
|| !CONTEXT_RESOURCES.has(item)) continue;
|
||||
result[key] = item;
|
||||
} else if (Number.isSafeInteger(item) && (item as number) >= 0) {
|
||||
result[key] = item as number;
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function validDecimal(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= 32
|
||||
&& DECIMAL_PATTERN.test(value);
|
||||
}
|
||||
|
||||
function validBillingReceipt(value: unknown): value is CapabilityBillingReceiptV1 {
|
||||
if (!isRecord(value) || typeof value.mode !== 'string' || typeof value.status !== 'string') return false;
|
||||
const keys = new Set(Object.keys(value));
|
||||
if (value.status === 'not_started') {
|
||||
return keys.size === 2 && ['unknown', 'included', 'platform_metered', 'external_account'].includes(value.mode);
|
||||
}
|
||||
if (value.mode === 'included' && value.status === 'included') return keys.size === 2;
|
||||
if (value.mode === 'external_account' && value.status === 'external') return keys.size === 2;
|
||||
if (value.mode !== 'platform_metered' || !validDecimal(value.reserved_points)) return false;
|
||||
const common = new Set(['mode', 'status', 'reserved_points', 'actual_points', 'usage_amount', 'unit']);
|
||||
if (keys.size !== [...keys].filter((key) => common.has(key)).length) return false;
|
||||
if (value.actual_points !== undefined && !validDecimal(value.actual_points)) return false;
|
||||
if (value.usage_amount !== undefined
|
||||
&& (typeof value.usage_amount !== 'number' || !Number.isFinite(value.usage_amount)
|
||||
|| value.usage_amount < 0 || value.usage_amount > Number.MAX_SAFE_INTEGER)) return false;
|
||||
if (value.unit !== undefined
|
||||
&& (typeof value.unit !== 'string' || value.unit.length === 0 || value.unit.length > 80)) return false;
|
||||
const status = value.status;
|
||||
if (['settled', 'refunded'].includes(status)) return value.actual_points !== undefined;
|
||||
return ['reserved', 'dispatched', 'released', 'pending_review', 'expired'].includes(status);
|
||||
}
|
||||
|
||||
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 : undefined;
|
||||
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 policyOperation(
|
||||
catalog: PluginCatalog,
|
||||
definition: CodingPluginDefinition,
|
||||
tool: CodingPluginToolDefinition,
|
||||
): PluginCatalogOperation | null {
|
||||
const plugin = catalog.plugins.find(({ plugin_id }) => plugin_id === definition.id);
|
||||
if (!plugin || plugin.status !== 'active' || !plugin.supported_contract_versions.includes(definition.contractVersion)) return null;
|
||||
const capability = plugin.capabilities.find(({ capability_id }) => capability_id === tool.capabilityId);
|
||||
return capability?.operations.find(({ operation }) => operation === tool.operation) ?? null;
|
||||
}
|
||||
|
||||
function definitionValid(definition: CodingPluginDefinition): boolean {
|
||||
if (!PLUGIN_ID_PATTERN.test(definition.id) || !VERSION_PATTERN.test(definition.version)
|
||||
|| !Number.isSafeInteger(definition.contractVersion) || definition.contractVersion < 1
|
||||
|| definition.scope !== 'project' || typeof definition.requiresBackend !== 'boolean') return false;
|
||||
if (!definition.skills.length || !definition.tools.length) return false;
|
||||
const skillIds = new Set<string>();
|
||||
for (const skill of definition.skills) {
|
||||
if (!skill.id || skillIds.has(skill.id) || !skill.entryPath || skill.grants.length === 0) return false;
|
||||
skillIds.add(skill.id);
|
||||
}
|
||||
const toolNames = new Set<string>();
|
||||
for (const tool of definition.tools) {
|
||||
if (!tool.name || toolNames.has(tool.name) || tool.roles.length !== 1 || tool.roles[0] !== 'parent'
|
||||
|| !CAPABILITY_ID_PATTERN.test(tool.capabilityId) || !OPERATION_PATTERN.test(tool.operation)
|
||||
|| !isRecord(tool.inputSchema) || tool.inputSchema.type !== 'object'
|
||||
|| tool.inputSchema.additionalProperties !== false) return false;
|
||||
toolNames.add(tool.name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function policyNotStarted(mode: PluginBillingMode | 'unknown'): CapabilityBillingReceiptV1 {
|
||||
return { mode, status: 'not_started' };
|
||||
}
|
||||
|
||||
function policyEntered(mode: PluginBillingMode): CapabilityBillingReceiptV1 {
|
||||
if (mode === 'included') return { mode, status: 'included' };
|
||||
if (mode === 'external_account') return { mode, status: 'external' };
|
||||
return { mode, status: 'not_started' };
|
||||
}
|
||||
|
||||
function resultDetails(
|
||||
definition: CodingPluginDefinition,
|
||||
tool: CodingPluginToolDefinition,
|
||||
context: PiProductToolContext,
|
||||
result: AdapterInvocationResult,
|
||||
billing: CapabilityBillingReceiptV1,
|
||||
): CapabilityResultV1 {
|
||||
const id = requestId(context);
|
||||
const rawStatus = boundedStatus(result.status, 502);
|
||||
const success = result.success && rawStatus >= 200 && rawStatus <= 299;
|
||||
const status = success ? rawStatus : rawStatus >= 400 ? rawStatus : 502;
|
||||
const payloadSchema = boundedText(result.payload_schema, 128, 'unknown');
|
||||
const code = success ? null : result.success
|
||||
? 'plugin_backend_unavailable'
|
||||
: boundedText(result.code, 128, 'plugin_backend_unavailable');
|
||||
const details: CapabilityResultV1 = {
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: definition.id,
|
||||
plugin_version: definition.version,
|
||||
capability_id: tool.capabilityId,
|
||||
operation: tool.operation,
|
||||
request_id: id,
|
||||
success,
|
||||
status,
|
||||
code,
|
||||
error: success ? null : boundedText(result.success ? null : result.error, 2_000, 'Plugin capability failed'),
|
||||
retryable: success ? false : result.success ? true : result.retryable,
|
||||
...(success || result.success ? {} : (boundedRetry(result.retry_after_seconds) === undefined
|
||||
? {} : { retry_after_seconds: boundedRetry(result.retry_after_seconds) })),
|
||||
...(success || result.success ? {} : (boundedContext(result.context) === undefined
|
||||
? {} : { context: boundedContext(result.context) })),
|
||||
billing,
|
||||
payload_schema: payloadSchema,
|
||||
data: success ? (result.data ?? null) : null,
|
||||
};
|
||||
return details;
|
||||
}
|
||||
|
||||
export function buildCapabilityToolResult(
|
||||
definition: CodingPluginDefinition,
|
||||
tool: CodingPluginToolDefinition,
|
||||
context: PiProductToolContext,
|
||||
result: AdapterInvocationResult,
|
||||
billing: CapabilityBillingReceiptV1 = { mode: 'included', status: 'included' },
|
||||
): PiProductToolResult {
|
||||
const details = resultDetails(definition, tool, context, result, billing);
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPort {
|
||||
private readonly definitions: readonly CodingPluginDefinition[];
|
||||
private readonly toolsByName: ReadonlyMap<string, { definition: CodingPluginDefinition; tool: CodingPluginToolDefinition }>;
|
||||
private readonly adaptersByPluginId: ReadonlyMap<string, CodingPluginAdapter>;
|
||||
|
||||
constructor(private readonly options: CodingCapabilityRegistryOptions) {
|
||||
const definitions = (options.definitions ?? BUNDLED_CODING_PLUGIN_DEFINITIONS).filter(definitionValid);
|
||||
this.definitions = Object.freeze([...definitions]);
|
||||
const tools = new Map<string, { definition: CodingPluginDefinition; tool: CodingPluginToolDefinition }>();
|
||||
for (const definition of this.definitions) {
|
||||
for (const tool of definition.tools) {
|
||||
if (!tools.has(tool.name)) tools.set(tool.name, { definition, tool });
|
||||
}
|
||||
}
|
||||
this.toolsByName = tools;
|
||||
this.adaptersByPluginId = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter]));
|
||||
}
|
||||
|
||||
async resolveWorkerResources(input: {
|
||||
projectPath: string;
|
||||
assignedSkillIds: readonly string[];
|
||||
role: 'parent' | 'child';
|
||||
}): Promise<ResolvedWorkerResources> {
|
||||
const assigned = [...new Set(input.assignedSkillIds)];
|
||||
const coreIds = new Set<string>(CORE_CODING_SKILL_IDS);
|
||||
const pluginSkillOwners = new Map<string, CodingPluginDefinition>();
|
||||
for (const definition of this.definitions) {
|
||||
for (const skill of definition.skills) pluginSkillOwners.set(skill.id, definition);
|
||||
}
|
||||
for (const id of assigned) {
|
||||
if (!coreIds.has(id) && !pluginSkillOwners.has(id)) throw new Error(`Unknown bundled coding skill: ${id}`);
|
||||
}
|
||||
const effectiveCoreSkills = assigned.filter((id) => coreIds.has(id));
|
||||
if (input.role === 'child') {
|
||||
return {
|
||||
catalogRevision: this.options.policyClient.getState().revision,
|
||||
pluginIds: [],
|
||||
effectiveSkillIds: effectiveCoreSkills,
|
||||
skillEntries: effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` })),
|
||||
tools: [],
|
||||
};
|
||||
}
|
||||
const enabled = await this.enabledPluginIds(input.projectPath);
|
||||
const state = this.options.policyClient.getState();
|
||||
const pluginIds: string[] = [];
|
||||
const effectiveSkillIds = [...effectiveCoreSkills];
|
||||
const skillEntries = effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` }));
|
||||
const tools: CodingPluginToolDefinition[] = [];
|
||||
for (const definition of this.definitions) {
|
||||
if (!definition.requiresBackend || !enabled.includes(definition.id)
|
||||
|| !this.adaptersByPluginId.has(definition.id)) continue;
|
||||
const pluginPolicy = state.catalog ? state.catalog.plugins.find(({ plugin_id }) => plugin_id === definition.id) : undefined;
|
||||
if (!pluginPolicy || pluginPolicy.status !== 'active' || !pluginPolicy.supported_contract_versions.includes(definition.contractVersion)) continue;
|
||||
pluginIds.push(definition.id);
|
||||
const selectedSkills = definition.skills.filter(({ id }) => assigned.includes(id));
|
||||
if (selectedSkills.length === 0) continue;
|
||||
for (const skill of selectedSkills) {
|
||||
effectiveSkillIds.push(skill.id);
|
||||
skillEntries.push({ id: skill.id, entryPath: skill.entryPath });
|
||||
}
|
||||
for (const tool of definition.tools) {
|
||||
const granted = selectedSkills.some(({ grants }) => grants.includes(tool.capabilityId));
|
||||
const policy = state.catalog ? policyOperation(state.catalog, definition, tool) : null;
|
||||
const available = policy !== null && (
|
||||
policy.billing.mode !== 'platform_metered'
|
||||
|| !('status' in policy.billing && policy.billing.status === 'billing_unavailable')
|
||||
);
|
||||
if (granted && available) {
|
||||
tools.push(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
catalogRevision: state.revision,
|
||||
pluginIds,
|
||||
effectiveSkillIds: [...new Set(effectiveSkillIds)],
|
||||
skillEntries,
|
||||
tools,
|
||||
};
|
||||
}
|
||||
|
||||
async invoke(input: {
|
||||
toolName: string;
|
||||
context: PiProductToolContext;
|
||||
workerRole: 'parent' | 'child';
|
||||
effectiveSkillIds: readonly string[];
|
||||
value: unknown;
|
||||
}): Promise<PiProductToolResult> {
|
||||
const indexed = this.toolsByName.get(input.toolName);
|
||||
const state = this.options.policyClient.getState();
|
||||
const validId = requestId(input.context) !== 'invalid-request-id';
|
||||
if (!indexed) return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin capability is unavailable');
|
||||
const { definition, tool } = indexed;
|
||||
const currentEnabled = await this.enabledPluginIds(input.context.projectPath);
|
||||
const catalogPolicy = state.catalog ? policyOperation(state.catalog, definition, tool) : null;
|
||||
const baseBilling = catalogPolicy ? policyNotStarted(catalogPolicy.billing.mode) : policyNotStarted('unknown');
|
||||
if (!validId) return this.resultFailure(definition, tool, input.context, 'plugin_input_invalid', 'Pi request identity is invalid', 422, false, baseBilling);
|
||||
if (input.workerRole !== 'parent' || !tool.roles.includes(input.workerRole)) {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_permission_denied', 'Plugin capability is not permitted for this worker', 403, false, baseBilling);
|
||||
}
|
||||
if (state.status === 'unavailable' || !state.catalog) {
|
||||
return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin policy is unavailable');
|
||||
}
|
||||
if (!currentEnabled.includes(definition.id)) {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_not_enabled', 'Plugin is not enabled for this project', 403, false, baseBilling);
|
||||
}
|
||||
if (!catalogPolicy) {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_contract_unsupported', 'Plugin capability is not supported by the server policy', 503, true, policyNotStarted('unknown'));
|
||||
}
|
||||
const selectedSkill = definition.skills.find(({ id }) => input.effectiveSkillIds.includes(id));
|
||||
if (!selectedSkill || !selectedSkill.grants.includes(tool.capabilityId)) {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_permission_denied', 'Plugin Skill grant is required', 403, false, baseBilling);
|
||||
}
|
||||
if (catalogPolicy.billing.mode === 'platform_metered'
|
||||
&& 'status' in catalogPolicy.billing && catalogPolicy.billing.status === 'billing_unavailable') {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_billing_unavailable', 'Plugin billing is unavailable', 503, true, baseBilling);
|
||||
}
|
||||
if (!schemaValid(input.value, tool.inputSchema)) {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_input_invalid', 'Plugin tool input is invalid', 422, false, baseBilling);
|
||||
}
|
||||
const adapter = this.adaptersByPluginId.get(definition.id);
|
||||
if (!adapter) return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is unavailable', 503, true, baseBilling);
|
||||
const durableProjectId = await this.options.getDurableProjectId?.(input.context.projectPath, input.context.projectId)
|
||||
?? input.context.projectId;
|
||||
const trustedContext: TrustedCodingCapabilityContext = {
|
||||
conversationId: input.context.conversationId,
|
||||
runId: input.context.runId,
|
||||
resourceId: input.context.resourceId,
|
||||
requestId: requestId(input.context),
|
||||
localProjectId: input.context.projectId,
|
||||
projectPath: input.context.projectPath,
|
||||
durableProjectId,
|
||||
workerRole: input.workerRole,
|
||||
effectiveSkillIds: [...input.effectiveSkillIds],
|
||||
};
|
||||
let result: AdapterInvocationResult;
|
||||
try {
|
||||
result = await adapter.invoke(trustedContext, tool, input.value);
|
||||
} catch {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_backend_unavailable', 'Plugin adapter is temporarily unavailable', 503, true, baseBilling);
|
||||
}
|
||||
let billing: CapabilityBillingReceiptV1;
|
||||
if (catalogPolicy.billing.mode === 'platform_metered') {
|
||||
if (!result.billing || !validBillingReceipt(result.billing) || result.billing.mode !== 'platform_metered') {
|
||||
return this.resultFailure(definition, tool, input.context, 'plugin_billing_unavailable', 'Plugin billing receipt is unavailable', 503, true, policyNotStarted('platform_metered'));
|
||||
}
|
||||
billing = result.billing;
|
||||
} else {
|
||||
billing = policyEntered(catalogPolicy.billing.mode);
|
||||
}
|
||||
return buildCapabilityToolResult(definition, tool, input.context, result, billing);
|
||||
}
|
||||
|
||||
private async enabledPluginIds(projectPath: string): Promise<readonly string[]> {
|
||||
if (this.options.getEnabledPluginIds) return await this.options.getEnabledPluginIds(projectPath);
|
||||
if (this.options.projectPlugins) return await this.options.projectPlugins.getEnabledPluginIds(projectPath);
|
||||
return [];
|
||||
}
|
||||
|
||||
private resultFailure(
|
||||
definition: CodingPluginDefinition,
|
||||
tool: CodingPluginToolDefinition,
|
||||
context: PiProductToolContext,
|
||||
code: string,
|
||||
error: string,
|
||||
status: number,
|
||||
retryable: boolean,
|
||||
billing: CapabilityBillingReceiptV1,
|
||||
): PiProductToolResult {
|
||||
return buildCapabilityToolResult(definition, tool, context, {
|
||||
success: false,
|
||||
status,
|
||||
code,
|
||||
error,
|
||||
retryable,
|
||||
payload_schema: 'data-service.v1',
|
||||
data: null,
|
||||
}, billing);
|
||||
}
|
||||
|
||||
private unknownResult(
|
||||
context: PiProductToolContext,
|
||||
code: string,
|
||||
error: string,
|
||||
): PiProductToolResult {
|
||||
const details: CapabilityResultV1 = {
|
||||
schema: 'makelore-capability.v1',
|
||||
plugin_id: 'unknown',
|
||||
plugin_version: 'unknown',
|
||||
capability_id: 'unknown',
|
||||
operation: 'unknown',
|
||||
request_id: requestId(context),
|
||||
success: false,
|
||||
status: 503,
|
||||
code,
|
||||
error,
|
||||
retryable: true,
|
||||
billing: { mode: 'unknown', status: 'not_started' },
|
||||
payload_schema: 'unknown',
|
||||
data: null,
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CodingCapabilityRegistry = CodingCapabilityRegistryImpl;
|
||||
|
||||
export const createCodingCapabilityRegistry = (
|
||||
options: CodingCapabilityRegistryOptions,
|
||||
): CodingCapabilityRegistry => new CodingCapabilityRegistryImpl(options);
|
||||
@@ -15,11 +15,18 @@ import type {
|
||||
} from '../../../shared/coding-product-tools';
|
||||
import {
|
||||
DATA_SERVICE_PI_TOOL_NAMES,
|
||||
type DataServiceHostResult,
|
||||
type DataServicePiToolName,
|
||||
type DataServiceToolData,
|
||||
type DataServiceToolDetailsV1,
|
||||
} from '../../../shared/data-service';
|
||||
import {
|
||||
DATA_SERVICE_PLUGIN_DEFINITION,
|
||||
} from '../../../shared/coding-plugins';
|
||||
import { createDataServicePluginAdapter } from '../../coding-plugins/adapters/data-service';
|
||||
import {
|
||||
buildCapabilityToolResult,
|
||||
type AdapterInvocationResult,
|
||||
type CodingCapabilityRegistry,
|
||||
type TrustedCodingCapabilityContext,
|
||||
} from '../../coding-plugins/registry';
|
||||
import type { DataServiceOperations } from '../../services/data-service-client';
|
||||
import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts';
|
||||
import { PiAgentBrowserTool } from './extensions/agent-browser';
|
||||
@@ -52,104 +59,6 @@ export function isPiProductToolName(value: unknown): value is PiProductToolName
|
||||
return typeof value === 'string' && PI_PRODUCT_TOOL_NAMES.has(value);
|
||||
}
|
||||
|
||||
const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/;
|
||||
const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
const MAX_REQUEST_BYTES = 98_304;
|
||||
const MAX_CURSOR_LENGTH = 1_024;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function invalidDataServiceInput(): never {
|
||||
throw new Error('Data Service tool input is invalid');
|
||||
}
|
||||
|
||||
function exactInput(
|
||||
value: unknown,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): Record<string, unknown> {
|
||||
if (!isRecord(value)) return invalidDataServiceInput();
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))
|
||||
|| required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function collection(value: unknown): string {
|
||||
if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) return invalidDataServiceInput();
|
||||
return value;
|
||||
}
|
||||
|
||||
function documentId(value: unknown): string {
|
||||
if (typeof value !== 'string' || value === '.' || value === '..'
|
||||
|| !DOCUMENT_ID_PATTERN.test(value)) return invalidDataServiceInput();
|
||||
return value;
|
||||
}
|
||||
|
||||
function revision(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) return invalidDataServiceInput();
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function collections(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 20) return invalidDataServiceInput();
|
||||
return value.map(collection);
|
||||
}
|
||||
|
||||
function data(value: unknown): Record<string, unknown> {
|
||||
if (!isRecord(value)) return invalidDataServiceInput();
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (typeof encoded !== 'string' || Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
} catch {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function limit(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1 || (value as number) > 100) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function cursor(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_CURSOR_LENGTH) {
|
||||
return invalidDataServiceInput();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function confirmation(value: unknown): true {
|
||||
if (value !== true) return invalidDataServiceInput();
|
||||
return true;
|
||||
}
|
||||
|
||||
function dataServiceResult<T extends DataServiceToolData | null>(
|
||||
operation: DataServicePiToolName,
|
||||
result: DataServiceHostResult<T>,
|
||||
): PiProductToolResult {
|
||||
const details: DataServiceToolDetailsV1 = {
|
||||
schema: 'data-service.v1',
|
||||
operation,
|
||||
...result,
|
||||
};
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiProductToolContext {
|
||||
conversationId: string;
|
||||
runId: string;
|
||||
@@ -170,22 +79,31 @@ export interface PiProductToolsOptions {
|
||||
bundledSkillsDir: string;
|
||||
changeTracker?: ConversationChangeTracker;
|
||||
dataService?: DataServiceOperations;
|
||||
capabilityRegistry?: CodingCapabilityRegistry;
|
||||
}
|
||||
|
||||
export class PiProductTools {
|
||||
readonly changeTracker: ConversationChangeTracker;
|
||||
private readonly browser: PiAgentBrowserTool;
|
||||
private readonly gameAssets = new PiGameAssetTools();
|
||||
private dataService: DataServiceOperations | undefined;
|
||||
private dataServiceAdapter: ReturnType<typeof createDataServicePluginAdapter> | undefined;
|
||||
private capabilityRegistry: CodingCapabilityRegistry | undefined;
|
||||
|
||||
constructor(private readonly options: PiProductToolsOptions) {
|
||||
this.changeTracker = options.changeTracker ?? new ConversationChangeTracker();
|
||||
this.browser = new PiAgentBrowserTool(options.browser, options.attachments);
|
||||
this.dataService = options.dataService;
|
||||
this.capabilityRegistry = options.capabilityRegistry;
|
||||
this.dataServiceAdapter = options.dataService
|
||||
? createDataServicePluginAdapter(options.dataService)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
configureDataService(dataService: DataServiceOperations): void {
|
||||
this.dataService = dataService;
|
||||
this.dataServiceAdapter = createDataServicePluginAdapter(dataService);
|
||||
}
|
||||
|
||||
configureCapabilityRegistry(registry: CodingCapabilityRegistry): void {
|
||||
this.capabilityRegistry = registry;
|
||||
}
|
||||
|
||||
beginRun(input: { conversationId: string; runId: string; projectPath: string }) {
|
||||
@@ -220,7 +138,7 @@ export class PiProductTools {
|
||||
}
|
||||
|
||||
async execute(
|
||||
toolName: PiProductToolName,
|
||||
toolName: PiProductToolName | string,
|
||||
context: PiProductToolContext,
|
||||
input: unknown,
|
||||
): Promise<PiProductToolResult> {
|
||||
@@ -237,76 +155,38 @@ export class PiProductTools {
|
||||
if (toolName === 'changed_file') {
|
||||
return await reportChangedFiles(this.changeTracker, context, input);
|
||||
}
|
||||
if (toolName === 'data_service_configure') {
|
||||
const body = exactInput(input, ['collections']);
|
||||
const result = await this.requireDataService().configure(
|
||||
{ collections: collections(body.collections) }, context.projectPath,
|
||||
if (this.capabilityRegistry && toolName !== 'runtime_context') {
|
||||
return await this.capabilityRegistry.invoke({
|
||||
toolName,
|
||||
context,
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: context.skillIds,
|
||||
value: input,
|
||||
});
|
||||
}
|
||||
if (DATA_SERVICE_PI_TOOL_NAMES.includes(toolName as DataServicePiToolName)) {
|
||||
const adapter = this.dataServiceAdapter;
|
||||
const definition = DATA_SERVICE_PLUGIN_DEFINITION.tools.find(({ name }) => name === toolName);
|
||||
if (!adapter || !definition) throw new Error('Data Service tools are unavailable');
|
||||
const trustedContext: TrustedCodingCapabilityContext = {
|
||||
conversationId: context.conversationId,
|
||||
runId: context.runId,
|
||||
resourceId: context.resourceId,
|
||||
requestId: `pi:${context.runId}:${context.resourceId}`,
|
||||
localProjectId: context.projectId,
|
||||
projectPath: context.projectPath,
|
||||
durableProjectId: context.projectId,
|
||||
workerRole: 'parent',
|
||||
effectiveSkillIds: [...context.skillIds],
|
||||
};
|
||||
const result: AdapterInvocationResult = await adapter.invoke(trustedContext, definition, input);
|
||||
return buildCapabilityToolResult(
|
||||
DATA_SERVICE_PLUGIN_DEFINITION,
|
||||
definition,
|
||||
context,
|
||||
result,
|
||||
{ mode: 'included', status: 'included' },
|
||||
);
|
||||
return dataServiceResult(toolName, result);
|
||||
}
|
||||
if (toolName === 'data_service_inspect') {
|
||||
exactInput(input, []);
|
||||
return dataServiceResult(toolName, await this.requireDataService().inspect(context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_list_projects') {
|
||||
exactInput(input, []);
|
||||
return dataServiceResult(toolName, await this.requireDataService().listProjects());
|
||||
}
|
||||
if (toolName === 'data_service_get_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().getDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_list_documents') {
|
||||
const body = exactInput(input, ['collection'], ['limit', 'cursor']);
|
||||
const requestedLimit = limit(body.limit);
|
||||
const requestedCursor = cursor(body.cursor);
|
||||
return dataServiceResult(toolName, await this.requireDataService().listDocuments({
|
||||
collection: collection(body.collection),
|
||||
...(requestedLimit === undefined ? {} : { limit: requestedLimit }),
|
||||
...(requestedCursor === undefined ? {} : { cursor: requestedCursor }),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_put_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id', 'data'], ['if_revision']);
|
||||
const requestedRevision = revision(body.if_revision);
|
||||
return dataServiceResult(toolName, await this.requireDataService().putDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
data: data(body.data),
|
||||
...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_delete_document') {
|
||||
const body = exactInput(input, ['collection', 'document_id', 'confirmed'], ['if_revision']);
|
||||
const requestedRevision = revision(body.if_revision);
|
||||
return dataServiceResult(toolName, await this.requireDataService().deleteDocument({
|
||||
collection: collection(body.collection),
|
||||
document_id: documentId(body.document_id),
|
||||
...(requestedRevision === undefined ? {} : { if_revision: requestedRevision }),
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_remove_collection') {
|
||||
const body = exactInput(input, ['collection', 'confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().removeCollection({
|
||||
collection: collection(body.collection),
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_reset') {
|
||||
const body = exactInput(input, ['confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().reset({
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName === 'data_service_remove_project') {
|
||||
const body = exactInput(input, ['confirmed']);
|
||||
return dataServiceResult(toolName, await this.requireDataService().removeProject({
|
||||
confirmed: confirmation(body.confirmed),
|
||||
}, context.projectPath));
|
||||
}
|
||||
if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable');
|
||||
const skills = await this.listSkills(context.skillIds);
|
||||
@@ -321,8 +201,4 @@ export class PiProductTools {
|
||||
};
|
||||
}
|
||||
|
||||
private requireDataService(): DataServiceOperations {
|
||||
if (!this.dataService) throw new Error('Data Service tools are unavailable');
|
||||
return this.dataService;
|
||||
}
|
||||
}
|
||||
|
||||
466
electron/services/plugin-policy-client.ts
Normal file
466
electron/services/plugin-policy-client.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
import { WORKS_SQUARE_CONFIG } from '../api/works-config';
|
||||
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||
|
||||
const MAX_CATALOG_BYTES = 1_310_720;
|
||||
const MAX_CATALOG_VERSION = 64;
|
||||
const MAX_PLUGIN_ID = 48;
|
||||
const MAX_CAPABILITY_ID = 64;
|
||||
const MAX_OPERATION_ID = 64;
|
||||
const MAX_NOTICE = 160;
|
||||
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u;
|
||||
const CAPABILITY_ID_PATTERN = /^[a-z][a-z0-9.-]{0,63}$/u;
|
||||
const OPERATION_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u;
|
||||
const TOKEN_POINT_AMOUNT_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u;
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export interface PluginPricingVersion {
|
||||
version: number;
|
||||
version_id: string;
|
||||
effective_at: string;
|
||||
}
|
||||
|
||||
export type PluginBillingPolicy =
|
||||
| {
|
||||
mode: 'included';
|
||||
entitlement_scope: null;
|
||||
notice: string;
|
||||
}
|
||||
| {
|
||||
mode: 'external_account';
|
||||
notice: string;
|
||||
}
|
||||
| {
|
||||
mode: 'platform_metered';
|
||||
entitlement_scope: string;
|
||||
notice: string;
|
||||
unit_name: string;
|
||||
unit_size: number;
|
||||
rate_points: string;
|
||||
minimum_charge_points: string;
|
||||
rounding_mode: 'ceil';
|
||||
}
|
||||
| {
|
||||
mode: 'platform_metered';
|
||||
status: 'billing_unavailable';
|
||||
entitlement_scope: string;
|
||||
notice: string;
|
||||
};
|
||||
|
||||
export interface PluginCatalogOperation {
|
||||
operation: string;
|
||||
billing: PluginBillingPolicy;
|
||||
}
|
||||
|
||||
export interface PluginCatalogCapability {
|
||||
capability_id: string;
|
||||
operations: PluginCatalogOperation[];
|
||||
}
|
||||
|
||||
export interface PluginCatalogPlugin {
|
||||
plugin_id: string;
|
||||
supported_contract_versions: number[];
|
||||
status: 'active';
|
||||
capabilities: PluginCatalogCapability[];
|
||||
}
|
||||
|
||||
export interface PluginCatalog {
|
||||
schema_version: 1;
|
||||
catalog_version: string;
|
||||
pricing_version: PluginPricingVersion | null;
|
||||
plugins: PluginCatalogPlugin[];
|
||||
}
|
||||
|
||||
export type PluginPolicyAvailability = 'unavailable' | 'current' | 'stale';
|
||||
|
||||
export interface PluginPolicyClientState {
|
||||
status: PluginPolicyAvailability;
|
||||
catalog: PluginCatalog | null;
|
||||
revision: number;
|
||||
lastVerifiedAt: number | null;
|
||||
errorCode?: 'plugin_backend_unavailable';
|
||||
}
|
||||
|
||||
export interface PluginPolicyClientOptions {
|
||||
fetchImpl?: FetchImplementation;
|
||||
apiBaseUrl?: string;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export class PluginPolicyCatalogError extends Error {
|
||||
readonly code = 'plugin_policy_invalid' as const;
|
||||
|
||||
constructor(message = 'Plugin policy catalog is invalid') {
|
||||
super(message);
|
||||
this.name = 'PluginPolicyCatalogError';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: UnknownRecord,
|
||||
required: readonly string[],
|
||||
optional: readonly string[] = [],
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
const keys = Object.keys(value);
|
||||
if (keys.some((key) => !allowed.has(key))
|
||||
|| required.some((key) => !Object.prototype.hasOwnProperty.call(value, key))) {
|
||||
throw new PluginPolicyCatalogError('Plugin policy catalog contains unexpected fields');
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown, maximum: number, field: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > maximum) {
|
||||
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(
|
||||
value: unknown,
|
||||
maximum: number,
|
||||
pattern: RegExp,
|
||||
field: string,
|
||||
): string {
|
||||
const result = text(value, maximum, field);
|
||||
if (!pattern.test(result)) throw new PluginPolicyCatalogError(`${field} has invalid shape`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, field: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
throw new PluginPolicyCatalogError(`${field} must be a positive integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function unique(values: readonly string[], field: string): void {
|
||||
if (new Set(values).size !== values.length) {
|
||||
throw new PluginPolicyCatalogError(`${field} contains duplicates`);
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, field: string): string {
|
||||
const result = text(value, 64, field);
|
||||
if (!result.endsWith('Z') || Number.isNaN(Date.parse(result))) {
|
||||
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function publicAmount(value: unknown, field: string): string {
|
||||
const result = text(value, 32, field);
|
||||
if (!TOKEN_POINT_AMOUNT_PATTERN.test(result)) {
|
||||
throw new PluginPolicyCatalogError(`${field} is invalid`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parsePricingVersion(value: unknown): PluginPricingVersion | null {
|
||||
if (value === null) return null;
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError('pricing_version is invalid');
|
||||
exactKeys(value, ['version', 'version_id', 'effective_at']);
|
||||
return {
|
||||
version: positiveInteger(value.version, 'pricing_version.version'),
|
||||
version_id: text(value.version_id, 36, 'pricing_version.version_id'),
|
||||
effective_at: timestamp(value.effective_at, 'pricing_version.effective_at'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseBilling(value: unknown): PluginBillingPolicy {
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError('billing is invalid');
|
||||
if (value.mode === 'included') {
|
||||
exactKeys(value, ['mode', 'entitlement_scope', 'notice']);
|
||||
if (value.entitlement_scope !== null) {
|
||||
throw new PluginPolicyCatalogError('included entitlement_scope must be null');
|
||||
}
|
||||
return {
|
||||
mode: 'included',
|
||||
entitlement_scope: null,
|
||||
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
||||
};
|
||||
}
|
||||
if (value.mode === 'external_account') {
|
||||
exactKeys(value, ['mode', 'notice']);
|
||||
return {
|
||||
mode: 'external_account',
|
||||
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
||||
};
|
||||
}
|
||||
if (value.mode !== 'platform_metered') {
|
||||
throw new PluginPolicyCatalogError('billing.mode is invalid');
|
||||
}
|
||||
if (value.status === 'billing_unavailable') {
|
||||
exactKeys(value, ['mode', 'status', 'entitlement_scope', 'notice']);
|
||||
return {
|
||||
mode: 'platform_metered',
|
||||
status: 'billing_unavailable',
|
||||
entitlement_scope: identifier(
|
||||
value.entitlement_scope,
|
||||
64,
|
||||
OPERATION_ID_PATTERN,
|
||||
'billing.entitlement_scope',
|
||||
),
|
||||
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
||||
};
|
||||
}
|
||||
exactKeys(value, [
|
||||
'mode',
|
||||
'entitlement_scope',
|
||||
'notice',
|
||||
'unit_name',
|
||||
'unit_size',
|
||||
'rate_points',
|
||||
'minimum_charge_points',
|
||||
'rounding_mode',
|
||||
]);
|
||||
if (value.rounding_mode !== 'ceil') {
|
||||
throw new PluginPolicyCatalogError('billing.rounding_mode is invalid');
|
||||
}
|
||||
return {
|
||||
mode: 'platform_metered',
|
||||
entitlement_scope: identifier(
|
||||
value.entitlement_scope,
|
||||
64,
|
||||
OPERATION_ID_PATTERN,
|
||||
'billing.entitlement_scope',
|
||||
),
|
||||
notice: text(value.notice, MAX_NOTICE, 'billing.notice'),
|
||||
unit_name: text(value.unit_name, 80, 'billing.unit_name'),
|
||||
unit_size: positiveInteger(value.unit_size, 'billing.unit_size'),
|
||||
rate_points: publicAmount(value.rate_points, 'billing.rate_points'),
|
||||
minimum_charge_points: publicAmount(
|
||||
value.minimum_charge_points,
|
||||
'billing.minimum_charge_points',
|
||||
),
|
||||
rounding_mode: 'ceil',
|
||||
};
|
||||
}
|
||||
|
||||
function parseOperation(value: unknown): PluginCatalogOperation {
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError('operation is invalid');
|
||||
exactKeys(value, ['operation', 'billing']);
|
||||
return {
|
||||
operation: identifier(value.operation, MAX_OPERATION_ID, OPERATION_ID_PATTERN, 'operation'),
|
||||
billing: parseBilling(value.billing),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCapability(value: unknown): PluginCatalogCapability {
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError('capability is invalid');
|
||||
exactKeys(value, ['capability_id', 'operations']);
|
||||
if (!Array.isArray(value.operations) || value.operations.length < 1 || value.operations.length > 256) {
|
||||
throw new PluginPolicyCatalogError('capability.operations is invalid');
|
||||
}
|
||||
const operations = value.operations.map(parseOperation);
|
||||
unique(operations.map(({ operation }) => operation), 'capability.operations');
|
||||
return {
|
||||
capability_id: identifier(
|
||||
value.capability_id,
|
||||
MAX_CAPABILITY_ID,
|
||||
CAPABILITY_ID_PATTERN,
|
||||
'capability_id',
|
||||
),
|
||||
operations,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePlugin(value: unknown): PluginCatalogPlugin {
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError('plugin is invalid');
|
||||
exactKeys(value, ['plugin_id', 'supported_contract_versions', 'status', 'capabilities']);
|
||||
if (value.status !== 'active') throw new PluginPolicyCatalogError('plugin.status is invalid');
|
||||
if (!Array.isArray(value.supported_contract_versions)
|
||||
|| value.supported_contract_versions.length < 1
|
||||
|| value.supported_contract_versions.length > 32) {
|
||||
throw new PluginPolicyCatalogError('supported_contract_versions is invalid');
|
||||
}
|
||||
const supportedContractVersions = value.supported_contract_versions.map((item) => (
|
||||
positiveInteger(item, 'supported_contract_versions')
|
||||
));
|
||||
if (new Set(supportedContractVersions).size !== supportedContractVersions.length) {
|
||||
throw new PluginPolicyCatalogError('supported_contract_versions contains duplicates');
|
||||
}
|
||||
if (!Array.isArray(value.capabilities) || value.capabilities.length < 1 || value.capabilities.length > 128) {
|
||||
throw new PluginPolicyCatalogError('plugin.capabilities is invalid');
|
||||
}
|
||||
const capabilities = value.capabilities.map(parseCapability);
|
||||
unique(capabilities.map(({ capability_id }) => capability_id), 'plugin.capabilities');
|
||||
return {
|
||||
plugin_id: identifier(value.plugin_id, MAX_PLUGIN_ID, PLUGIN_ID_PATTERN, 'plugin_id'),
|
||||
supported_contract_versions: supportedContractVersions,
|
||||
status: 'active',
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the exact public server catalog; unknown fields are rejected. */
|
||||
export function parsePluginCatalog(value: unknown): PluginCatalog {
|
||||
if (!isRecord(value)) throw new PluginPolicyCatalogError();
|
||||
exactKeys(value, ['schema_version', 'catalog_version', 'pricing_version', 'plugins']);
|
||||
if (value.schema_version !== 1) throw new PluginPolicyCatalogError('schema_version must equal 1');
|
||||
if (!Array.isArray(value.plugins) || value.plugins.length > 128) {
|
||||
throw new PluginPolicyCatalogError('plugins is invalid');
|
||||
}
|
||||
const plugins = value.plugins.map(parsePlugin);
|
||||
unique(plugins.map(({ plugin_id }) => plugin_id), 'plugins');
|
||||
return Object.freeze({
|
||||
schema_version: 1 as const,
|
||||
catalog_version: text(value.catalog_version, MAX_CATALOG_VERSION, 'catalog_version'),
|
||||
pricing_version: parsePricingVersion(value.pricing_version),
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
|
||||
async function readBoundedText(response: Response): Promise<string> {
|
||||
const declaredLength = response.headers.get('content-length');
|
||||
if (declaredLength && /^\d+$/u.test(declaredLength)
|
||||
&& Number(declaredLength) > MAX_CATALOG_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new PluginPolicyCatalogError('catalog response is too large');
|
||||
}
|
||||
if (!response.body) {
|
||||
const textValue = await response.text();
|
||||
if (Buffer.byteLength(textValue, 'utf8') > MAX_CATALOG_BYTES) {
|
||||
throw new PluginPolicyCatalogError('catalog response is too large');
|
||||
}
|
||||
return textValue;
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
size += value.byteLength;
|
||||
if (size > MAX_CATALOG_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new PluginPolicyCatalogError('catalog response is too large');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8');
|
||||
}
|
||||
|
||||
async function readCatalog(response: Response): Promise<PluginCatalog> {
|
||||
let source: string;
|
||||
try {
|
||||
source = await readBoundedText(response);
|
||||
} catch (error) {
|
||||
if (error instanceof PluginPolicyCatalogError) throw error;
|
||||
throw new PluginPolicyCatalogError();
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(source) as unknown;
|
||||
} catch {
|
||||
throw new PluginPolicyCatalogError();
|
||||
}
|
||||
return parsePluginCatalog(payload);
|
||||
}
|
||||
|
||||
function cloneState(state: PluginPolicyClientState): PluginPolicyClientState {
|
||||
return {
|
||||
...state,
|
||||
catalog: state.catalog ? structuredClone(state.catalog) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow HTTP adapter for the unauthenticated, user-independent policy catalog.
|
||||
* Only the last successfully parsed catalog is retained after a refresh error;
|
||||
* it is visibly stale and never becomes a source of billing authority.
|
||||
*/
|
||||
export class PluginPolicyClient {
|
||||
private readonly fetchImpl: FetchImplementation;
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly now: () => number;
|
||||
private state: PluginPolicyClientState = {
|
||||
status: 'unavailable',
|
||||
catalog: null,
|
||||
revision: 0,
|
||||
lastVerifiedAt: null,
|
||||
};
|
||||
private refreshFlight: Promise<PluginPolicyClientState> | null = null;
|
||||
|
||||
constructor(options: PluginPolicyClientOptions = {}) {
|
||||
this.fetchImpl = options.fetchImpl ?? proxyAwareFetch;
|
||||
this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, '');
|
||||
this.now = options.now ?? (() => Date.now());
|
||||
}
|
||||
|
||||
getState(): PluginPolicyClientState {
|
||||
return cloneState(this.state);
|
||||
}
|
||||
|
||||
getStatus(): PluginPolicyAvailability {
|
||||
return this.state.status;
|
||||
}
|
||||
|
||||
get catalog(): PluginCatalog | null {
|
||||
return this.state.catalog ? structuredClone(this.state.catalog) : null;
|
||||
}
|
||||
|
||||
refresh(): Promise<PluginPolicyClientState> {
|
||||
if (this.refreshFlight) return this.refreshFlight;
|
||||
const flight = this.fetchCatalog()
|
||||
.then((catalog) => {
|
||||
this.state = {
|
||||
status: 'current',
|
||||
catalog,
|
||||
revision: this.state.revision + 1,
|
||||
lastVerifiedAt: this.now(),
|
||||
};
|
||||
return this.getState();
|
||||
})
|
||||
.catch(() => {
|
||||
this.state = {
|
||||
...this.state,
|
||||
status: this.state.catalog ? 'stale' : 'unavailable',
|
||||
errorCode: 'plugin_backend_unavailable',
|
||||
};
|
||||
return this.getState();
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.refreshFlight === flight) this.refreshFlight = null;
|
||||
});
|
||||
this.refreshFlight = flight;
|
||||
return flight;
|
||||
}
|
||||
|
||||
refreshCatalog(): Promise<PluginPolicyClientState> {
|
||||
return this.refresh();
|
||||
}
|
||||
|
||||
private async fetchCatalog(): Promise<PluginCatalog> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(
|
||||
`${this.apiBaseUrl}/api/plugins/v1/catalog`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
redirect: 'manual',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw new PluginPolicyCatalogError('catalog request failed');
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new PluginPolicyCatalogError('catalog request failed');
|
||||
}
|
||||
return await readCatalog(response);
|
||||
}
|
||||
}
|
||||
|
||||
export const parsePluginPolicyCatalog = parsePluginCatalog;
|
||||
Reference in New Issue
Block a user