feat(coding): compose plugin host lifecycle

This commit is contained in:
2026-08-27 18:02:10 +08:00
parent a92cd904d3
commit a18727ecf8
9 changed files with 985 additions and 34 deletions

View File

@@ -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,