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

@@ -35,6 +35,17 @@ import { createDataServiceOperations } from '../services/data-service-client';
import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session';
import { archivePiConversationSession } from '../coding-runtime/pi/resource-loader';
import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1';
import { createProjectPluginService } from '../coding-plugins/project-service';
import {
createCodingCapabilityRegistry,
type CodingCapabilityRegistry,
} from '../coding-plugins/registry';
import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service';
import { PluginPolicyClient } from '../services/plugin-policy-client';
import {
createCodingProjectPluginService,
type CodingProjectPluginService,
} from './coding-product-services';
export interface CodingCompositionPaths {
executablePath: string;
@@ -106,6 +117,85 @@ export function createCodingComposition(
accounts: await getProviderService().listAccounts(),
modelSummaries: [],
});
let runtime: PiConversationRuntime | undefined;
let plugins: CodingProjectPluginService | undefined;
let previewDataSession: PreviewDataSessionManager | undefined;
const projects = new CodingProjectService(projectStore, {
migration: {
resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel(
legacyModel,
await getProviderService().listAccounts(),
),
},
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime?.markResourcesStale();
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectIdentityChanging: async (project) => {
previewDataSession?.invalidate('project_identity_changed');
await plugins?.deactivate(project.path);
await options.browser.close(project.path);
},
onProjectDeactivated: async (project, reason) => {
previewDataSession?.invalidate('project_deactivated');
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([
plugins?.deactivate(project.path),
options.browser.close(project.path),
...conversations.map(({ id }) => runtime?.dispose(id, reason)),
]);
},
});
const dataService = createDataServiceOperations({ projects });
const dataServiceAdapter = createDataServicePluginAdapter(dataService);
const policyClient = new PluginPolicyClient();
const projectPlugins = createProjectPluginService({
onManagedInputsChanged: async ({ projectPath }) => {
runtime?.markResourcesStale();
const conversations = await conversationStoreForProject(projectPath).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onAdapterDeactivated: async ({ projectPath, pluginId }) => {
previewDataSession?.invalidate('project_deactivated');
await plugins?.deactivate(projectPath, pluginId);
},
});
const capabilityRegistry = createCodingCapabilityRegistry({
policyClient,
projectPlugins,
adapters: [dataServiceAdapter],
getDurableProjectId: async (projectPath, localProjectId) => {
const active = await projects.requireActiveRealProjectWithIdentity(projectPath);
if (active.project.id !== localProjectId) {
throw new Error('The trusted coding project does not match the active project');
}
return active.projectId;
},
});
const refreshingCapabilityRegistry: CodingCapabilityRegistry = {
async resolveWorkerResources(input) {
await policyClient.refresh();
return await capabilityRegistry.resolveWorkerResources(input);
},
async invoke(input) {
return await capabilityRegistry.invoke(input);
},
};
productTools.configureCapabilityRegistry(refreshingCapabilityRegistry);
plugins = createCodingProjectPluginService({
projects,
projectPlugins,
policyClient,
adapters: [dataServiceAdapter],
});
const workerPool = new PiWorkerPool({
processBudget,
revisionCoordinator: revisions,
@@ -121,6 +211,7 @@ export function createCodingComposition(
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
: {}),
extensionHost,
capabilityRegistry: refreshingCapabilityRegistry,
}),
});
const childOpener = createPiManagedSubagentChildOpener({
@@ -136,13 +227,14 @@ export function createCodingComposition(
...(getLocalProxyCredential
? { getLocalProxyCredential: async () => getLocalProxyCredential() }
: {}),
capabilityRegistry: refreshingCapabilityRegistry,
});
const subagents = new PiSubagentScheduler({
openChild: childOpener,
processBudget,
reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal),
});
const runtime = new PiConversationRuntime({
runtime = new PiConversationRuntime({
pool: workerPool,
registry,
extensionHost,
@@ -165,37 +257,6 @@ export function createCodingComposition(
? { acquireBackgroundLease: options.acquireBackgroundLease }
: {}),
});
let previewDataSession: PreviewDataSessionManager | undefined;
const projects = new CodingProjectService(projectStore, {
migration: {
resolveLegacyModel: async ({ legacyModel }) => resolveLegacyProjectModel(
legacyModel,
await getProviderService().listAccounts(),
),
},
createConversationStore: conversationStoreForProject,
onResourcesChanged: async (project) => {
runtime.markResourcesStale();
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectIdentityChanging: async (project) => {
previewDataSession?.invalidate('project_identity_changed');
await options.browser.close(project.path);
},
onProjectDeactivated: async (project, reason) => {
previewDataSession?.invalidate('project_deactivated');
const conversations = await conversationStoreForProject(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([
options.browser.close(project.path),
...conversations.map(({ id }) => runtime.dispose(id, reason)),
]);
},
});
const conversations = new CodingConversationService(projects, runtime, {
archiveSession: async ({ projectId, sessionKey }) => {
await archivePiConversationSession({
@@ -210,8 +271,6 @@ export function createCodingComposition(
productTools,
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
});
const dataService = createDataServiceOperations({ projects });
productTools.configureDataService(dataService);
previewDataSession = createPreviewDataSessionManager({ projects });
if (typeof options.browser.configurePreviewDataSession === 'function') {
options.browser.configurePreviewDataSession(previewDataSession);
@@ -224,6 +283,7 @@ export function createCodingComposition(
return {
attachments,
dataService,
plugins,
previewDataSession,
productTools,
projects,
@@ -232,6 +292,10 @@ export function createCodingComposition(
host,
async sleep(reason) {
if (reason === 'background_sleep' && runtime.hasActiveWork()) return;
if (reason === 'auth_cleanup') {
const active = await projects.getActiveProject();
if (active) await plugins?.deactivate(active.path);
}
const conversationIds = runtime.getDiagnostics().workers.map((worker) => worker.conversationId);
await Promise.allSettled(conversationIds.map((conversationId) => (
runtime.dispose(conversationId, reason)
@@ -243,6 +307,8 @@ export function createCodingComposition(
options.browser.configurePreviewDataSession(undefined);
}
unsubscribeBrowserLifecycle();
const active = await projects.getActiveProject();
if (active) await plugins?.deactivate(active.path);
await subagents.close();
await runtime.shutdown();
},

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,

View File

@@ -21,6 +21,7 @@ import { handleCodingFileRoutes } from './routes/coding-files';
import { handleCodingAttachmentRoutes } from './routes/coding-attachments';
import { handleCodingProjectRoutes } from './routes/coding-projects';
import { handleCodingConversationRoutes } from './routes/coding-conversations';
import { handleCodingPluginRoutes } from './routes/coding-plugins';
export type HostApiRouteHandler = (
req: IncomingMessage,
@@ -49,6 +50,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleUserSyncRoutes,
handleCodingAttachmentRoutes,
handleCodingProjectRoutes,
handleCodingPluginRoutes,
handleCodingConversationRoutes,
handleCodingFileRoutes,
handleSettingsRoutes,

View File

@@ -0,0 +1,130 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { ProjectPluginServiceError } from '../../coding-plugins/project-service';
import { CodingProjectServiceError } from '../../coding-projects/project-service';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
import { CodingProjectPluginServiceError } from '../coding-product-services';
const PLUGIN_ROUTE = /^\/api\/coding\/plugins\/([^/]+)$/u;
const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u;
class CodingPluginRouteError extends Error {
constructor(
readonly status: 400 | 404 | 503,
readonly code: string,
message: string,
) {
super(message);
this.name = 'CodingPluginRouteError';
}
}
function requestError(message: string): never {
throw new CodingPluginRouteError(400, 'plugin_request_invalid', message);
}
function exactProjectId(url: URL): string {
if ([...url.searchParams.keys()].some((key) => key !== 'projectId')
|| url.searchParams.getAll('projectId').length !== 1) {
return requestError('GET requires exactly one projectId query parameter');
}
const projectId = url.searchParams.get('projectId')?.trim() ?? '';
if (!projectId || projectId.length > 128) return requestError('projectId is invalid');
return projectId;
}
function exactPutBody(value: unknown): { projectId: string; enabled: boolean } {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return requestError('Request body must be an object');
}
const body = value as Record<string, unknown>;
const keys = Object.keys(body).sort();
if (keys.length !== 2 || keys[0] !== 'enabled' || keys[1] !== 'projectId') {
return requestError('Request body must contain only projectId and enabled');
}
if (typeof body.projectId !== 'string' || !body.projectId.trim() || body.projectId.length > 128) {
return requestError('projectId is invalid');
}
if (typeof body.enabled !== 'boolean') return requestError('enabled is invalid');
return { projectId: body.projectId.trim(), enabled: body.enabled };
}
function routePluginId(value: string): string {
let id: string;
try {
id = decodeURIComponent(value).trim();
} catch {
return requestError('plugin id is invalid');
}
if (!PLUGIN_ID_PATTERN.test(id)) return requestError('plugin id is invalid');
return id;
}
function sendError(res: ServerResponse, error: unknown): void {
if (error instanceof CodingPluginRouteError || error instanceof CodingProjectPluginServiceError) {
sendJson(res, error.status, { success: false, code: error.code, error: error.message });
return;
}
if (error instanceof CodingProjectServiceError) {
sendJson(res, error.status, { success: false, code: error.code, error: error.message });
return;
}
if (error instanceof ProjectPluginServiceError) {
const status = error.code === 'CODING_PLUGIN_UNKNOWN' ? 404
: error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 500
: 400;
const code = error.code === 'CODING_PLUGIN_UNKNOWN' ? 'plugin_not_found'
: error.code === 'CODING_PLUGIN_SELECTION_WRITE_FAILED' ? 'plugin_selection_write_failed'
: 'plugin_request_invalid';
sendJson(res, status, { success: false, code, error: error.message });
return;
}
if (error instanceof SyntaxError) {
sendJson(res, 400, { success: false, code: 'plugin_request_invalid', error: 'Request body is invalid' });
return;
}
sendJson(res, 503, {
success: false,
code: 'plugin_backend_unavailable',
error: 'Plugin service is temporarily unavailable',
});
}
export async function handleCodingPluginRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname !== '/api/coding/plugins' && !PLUGIN_ROUTE.test(url.pathname)) return false;
const plugins = ctx.codingProducts?.plugins;
if (!plugins) {
sendJson(res, 503, {
success: false,
code: 'plugin_backend_unavailable',
error: 'Plugin service is temporarily unavailable',
});
return true;
}
try {
if (url.pathname === '/api/coding/plugins' && req.method === 'GET') {
sendJson(res, 200, await plugins.list(exactProjectId(url)));
return true;
}
const match = PLUGIN_ROUTE.exec(url.pathname);
if (match && req.method === 'PUT') {
if (url.search) requestError('PUT does not accept query parameters');
const body = exactPutBody(await parseJsonBody<unknown>(req));
sendJson(res, 200, await plugins.setEnabled(
body.projectId,
routePluginId(match[1]),
body.enabled,
));
return true;
}
return false;
} catch (error) {
sendError(res, error);
return true;
}
}