feat(coding): compose plugin host lifecycle
This commit is contained in:
@@ -19,6 +19,23 @@ import type { CodingConversationRuntime } from '../coding-runtime/contracts';
|
||||
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
|
||||
import type { DataServiceOperations } from '../services/data-service-client';
|
||||
import type { PreviewDataSessionManager } from '../services/preview-data-session';
|
||||
import {
|
||||
BUNDLED_CODING_PLUGIN_DEFINITIONS,
|
||||
type CodingPluginDefinition,
|
||||
type PluginBillingMode,
|
||||
} from '../../shared/coding-plugins';
|
||||
import type {
|
||||
CodingPluginAdapter,
|
||||
PluginBackendProjection,
|
||||
} from '../coding-plugins/registry';
|
||||
import type { ProjectPluginService } from '../coding-plugins/project-service';
|
||||
import type {
|
||||
PluginBillingPolicy,
|
||||
PluginCatalogOperation,
|
||||
PluginPolicyAvailability,
|
||||
PluginPolicyClient,
|
||||
PluginPolicyClientState,
|
||||
} from '../services/plugin-policy-client';
|
||||
|
||||
export interface ActiveCodingProject {
|
||||
id: string;
|
||||
@@ -40,6 +57,7 @@ export interface CodingProductComposition {
|
||||
dataService: DataServiceOperations;
|
||||
previewDataSession?: PreviewDataSessionManager;
|
||||
productTools: PiProductTools;
|
||||
plugins: CodingProjectPluginService;
|
||||
projects: CodingProjectService;
|
||||
conversations: CodingConversationService;
|
||||
runtime: CodingConversationRuntime;
|
||||
@@ -48,6 +66,275 @@ export interface CodingProductComposition {
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
export type CodingPluginEffectiveState =
|
||||
| 'unavailable'
|
||||
| 'disabled'
|
||||
| 'identity_required'
|
||||
| 'authentication_required'
|
||||
| 'configuration_required'
|
||||
| 'ready'
|
||||
| 'degraded';
|
||||
|
||||
export type PublicPluginBilling = Readonly<{
|
||||
mode: PluginBillingMode;
|
||||
availability: 'available' | 'unavailable';
|
||||
notice: string;
|
||||
pricingVersion?: number;
|
||||
unitName?: string;
|
||||
unitSize?: number;
|
||||
ratePoints?: string;
|
||||
minimumChargePoints?: string;
|
||||
roundingMode?: 'ceil';
|
||||
}>;
|
||||
|
||||
export interface CodingPluginProjectProjection {
|
||||
schemaVersion: 1;
|
||||
project: {
|
||||
localProjectId: string;
|
||||
durableProjectId: string | null;
|
||||
};
|
||||
policyStatus: PluginPolicyAvailability;
|
||||
items: Array<{
|
||||
id: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
state: CodingPluginEffectiveState;
|
||||
backend: PluginBackendProjection;
|
||||
skills: Array<{ id: string; assignedAgentIds: string[] }>;
|
||||
capabilities: Array<{
|
||||
id: string;
|
||||
operations: Array<{ id: string; billing: PublicPluginBilling }>;
|
||||
}>;
|
||||
settingsSurface: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CodingProjectPluginService {
|
||||
list(localProjectId: string): Promise<CodingPluginProjectProjection>;
|
||||
setEnabled(
|
||||
localProjectId: string,
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
): Promise<CodingPluginProjectProjection>;
|
||||
deactivate(projectPath: string, pluginId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class CodingProjectPluginServiceError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 500 | 503,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CodingProjectPluginServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateCodingProjectPluginServiceOptions {
|
||||
projects: Pick<CodingProjectService, 'getProject'>;
|
||||
projectPlugins: Pick<ProjectPluginService, 'getEnabledPluginIds' | 'setEnabled'>;
|
||||
policyClient: Pick<PluginPolicyClient, 'refresh' | 'getState'>;
|
||||
adapters: readonly CodingPluginAdapter[];
|
||||
definitions?: readonly CodingPluginDefinition[];
|
||||
}
|
||||
|
||||
function policyOperation(
|
||||
state: PluginPolicyClientState,
|
||||
definition: CodingPluginDefinition,
|
||||
capabilityId: string,
|
||||
operation: string,
|
||||
): PluginCatalogOperation | null {
|
||||
const plugin = state.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
|
||||
if (!plugin || !plugin.supported_contract_versions.includes(definition.contractVersion)) return null;
|
||||
return plugin.capabilities
|
||||
.find(({ capability_id }) => capability_id === capabilityId)
|
||||
?.operations.find((candidate) => candidate.operation === operation) ?? null;
|
||||
}
|
||||
|
||||
function publicBilling(
|
||||
policy: PluginBillingPolicy,
|
||||
state: PluginPolicyClientState,
|
||||
): PublicPluginBilling {
|
||||
if (policy.mode === 'included' || policy.mode === 'external_account') {
|
||||
return { mode: policy.mode, availability: 'available', notice: policy.notice };
|
||||
}
|
||||
if ('status' in policy && policy.status === 'billing_unavailable') {
|
||||
return {
|
||||
mode: 'platform_metered',
|
||||
availability: 'unavailable',
|
||||
notice: policy.notice,
|
||||
};
|
||||
}
|
||||
const pricingVersion = state.catalog?.pricing_version?.version;
|
||||
return {
|
||||
mode: 'platform_metered',
|
||||
availability: 'available',
|
||||
notice: policy.notice,
|
||||
...(pricingVersion === undefined ? {} : { pricingVersion }),
|
||||
unitName: policy.unit_name,
|
||||
unitSize: policy.unit_size,
|
||||
ratePoints: policy.rate_points,
|
||||
minimumChargePoints: policy.minimum_charge_points,
|
||||
roundingMode: policy.rounding_mode,
|
||||
};
|
||||
}
|
||||
|
||||
function degradedBackend(): PluginBackendProjection {
|
||||
return {
|
||||
status: 'degraded',
|
||||
code: 'plugin_backend_unavailable',
|
||||
message: 'Plugin backend is temporarily unavailable',
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function boundedBackend(value: PluginBackendProjection): PluginBackendProjection {
|
||||
switch (value.status) {
|
||||
case 'not_required': return { status: 'not_required' };
|
||||
case 'identity_required': return { status: 'identity_required' };
|
||||
case 'authentication_required': return { status: 'authentication_required' };
|
||||
case 'unconfigured': return { status: 'unconfigured' };
|
||||
case 'ready': return { status: 'ready' };
|
||||
case 'degraded': {
|
||||
const code = typeof value.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(value.code)
|
||||
? value.code
|
||||
: 'plugin_backend_unavailable';
|
||||
const message = typeof value.message === 'string' && value.message.length > 0
|
||||
&& value.message.length <= 160
|
||||
? value.message
|
||||
: 'Plugin backend is temporarily unavailable';
|
||||
const retryAfter = value.retry_after_seconds;
|
||||
return {
|
||||
status: 'degraded',
|
||||
code,
|
||||
message,
|
||||
retryable: value.retryable === true,
|
||||
...(Number.isSafeInteger(retryAfter) && (retryAfter as number) >= 0
|
||||
&& (retryAfter as number) <= 86_400
|
||||
? { retry_after_seconds: retryAfter }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function effectiveState(input: {
|
||||
enabled: boolean;
|
||||
policyAvailable: boolean;
|
||||
backend: PluginBackendProjection;
|
||||
}): CodingPluginEffectiveState {
|
||||
if (!input.policyAvailable) return 'unavailable';
|
||||
if (!input.enabled) return 'disabled';
|
||||
switch (input.backend.status) {
|
||||
case 'identity_required': return 'identity_required';
|
||||
case 'authentication_required': return 'authentication_required';
|
||||
case 'unconfigured': return 'configuration_required';
|
||||
case 'ready':
|
||||
case 'not_required': return 'ready';
|
||||
case 'degraded': return 'degraded';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded Renderer-facing projection. It resolves a local project handle in
|
||||
* Main, joins only code-owned package fields with verified policy, and keeps
|
||||
* adapters and project paths behind the composition boundary.
|
||||
*/
|
||||
export function createCodingProjectPluginService(
|
||||
options: CreateCodingProjectPluginServiceOptions,
|
||||
): CodingProjectPluginService {
|
||||
const definitions = options.definitions ?? BUNDLED_CODING_PLUGIN_DEFINITIONS;
|
||||
const adapters = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter]));
|
||||
|
||||
async function project(localProjectId: string) {
|
||||
const id = localProjectId.trim();
|
||||
if (!id) {
|
||||
throw new CodingProjectPluginServiceError(400, 'plugin_request_invalid', 'projectId is required');
|
||||
}
|
||||
return await options.projects.getProject(id);
|
||||
}
|
||||
|
||||
async function list(localProjectId: string): Promise<CodingPluginProjectProjection> {
|
||||
const localProject = await project(localProjectId);
|
||||
await options.policyClient.refresh();
|
||||
const policy = options.policyClient.getState();
|
||||
const [selection, config] = await Promise.all([
|
||||
options.projectPlugins.getEnabledPluginIds(localProject.path),
|
||||
readCodingProjectConfigV2(localProject.path),
|
||||
]);
|
||||
const durableProjectId = config.status === 'valid' ? config.config.projectId ?? null : null;
|
||||
const items = await Promise.all(definitions.map(async (definition) => {
|
||||
const enabled = selection.includes(definition.id);
|
||||
const adapter = adapters.get(definition.id);
|
||||
let backend: PluginBackendProjection;
|
||||
try {
|
||||
backend = adapter ? boundedBackend(await adapter.inspect(localProject.path)) : degradedBackend();
|
||||
} catch {
|
||||
backend = degradedBackend();
|
||||
}
|
||||
const pluginPolicy = policy.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
|
||||
const policyAvailable = Boolean(
|
||||
pluginPolicy?.supported_contract_versions.includes(definition.contractVersion),
|
||||
);
|
||||
const capabilityIds = [...new Set(definition.tools.map(({ capabilityId }) => capabilityId))];
|
||||
const capabilities = capabilityIds.flatMap((capabilityId) => {
|
||||
const operations = definition.tools
|
||||
.filter((tool) => tool.capabilityId === capabilityId)
|
||||
.flatMap((tool) => {
|
||||
const joined = policyOperation(policy, definition, capabilityId, tool.operation);
|
||||
return joined ? [{ id: tool.operation, billing: publicBilling(joined.billing, policy) }] : [];
|
||||
});
|
||||
return operations.length > 0 ? [{ id: capabilityId, operations }] : [];
|
||||
});
|
||||
const agents = config.status === 'valid' ? config.config.agents : [];
|
||||
return {
|
||||
id: definition.id,
|
||||
version: definition.version,
|
||||
displayName: definition.displayName,
|
||||
description: definition.description,
|
||||
enabled,
|
||||
state: effectiveState({ enabled, policyAvailable, backend }),
|
||||
backend,
|
||||
skills: definition.skills.map((skill) => ({
|
||||
id: skill.id,
|
||||
assignedAgentIds: agents
|
||||
.filter((agent) => agent.skillIds.includes(skill.id))
|
||||
.map(({ id }) => id)
|
||||
.sort(),
|
||||
})),
|
||||
capabilities,
|
||||
settingsSurface: definition.surfaces.projectSettings ?? null,
|
||||
};
|
||||
}));
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
project: { localProjectId: localProject.id, durableProjectId },
|
||||
policyStatus: policy.status,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
async setEnabled(localProjectId, pluginId, enabled) {
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new CodingProjectPluginServiceError(400, 'plugin_request_invalid', 'enabled must be boolean');
|
||||
}
|
||||
const localProject = await project(localProjectId);
|
||||
await options.projectPlugins.setEnabled(localProject.path, pluginId, enabled);
|
||||
return await list(localProject.id);
|
||||
},
|
||||
async deactivate(projectPath, pluginId) {
|
||||
const targets = pluginId ? [adapters.get(pluginId)].filter(Boolean) : [...adapters.values()];
|
||||
await Promise.allSettled(targets.map(async (adapter) => {
|
||||
await adapter.deactivate?.(projectPath);
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class CodingProductHostError extends Error {
|
||||
constructor(
|
||||
readonly status: 404 | 409,
|
||||
|
||||
Reference in New Issue
Block a user