diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml04-host-lifecycle-4e7a2c91.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml04-host-lifecycle-4e7a2c91.md new file mode 100644 index 0000000..4947b41 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml04-host-lifecycle-4e7a2c91.md @@ -0,0 +1,133 @@ +# Task: Implement ML-04 Host routes and lifecycle + +## Identity + +- Task ID: 20260827-plugin-ml04-host-lifecycle-4e7a2c91 +- Mode: Feature +- Branch: codex/20260827-plugin-ml04-host-lifecycle-4e7a2c91-plugin-ml04-host-lifecycle +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml04-host-lifecycle-4e7a2c91 +- Base commit: a92cd904d33d4fa0b7c2413188186852a39eb6d4 +- Owner: codex-ml04 +- Status: Ready for integration + +## Scope + +- Implement ML-04 from exact coordinator frontier + `a92cd904d33d4fa0b7c2413188186852a39eb6d4`. +- Own only Main composition, Host project-plugin routes, lifecycle wiring, the + explicitly listed focused tests, and this task record: + `electron/api/routes/coding-plugins.ts`, `electron/api/context.ts`, + `electron/api/route-handlers.ts`, `electron/api/coding-product-services.ts`, + `electron/api/coding-composition.ts`, `electron/api/server.ts` only when + exact registration requires it, and `electron/main/index.ts` shutdown wiring + only. +- Do not modify ML-01 project-service source/tests, preview session code, + Renderer/ML-05, or P1 paths without an explicit coordinator transfer. + +## Intent And Constraints + +- Follow implementation spec §7.5 and §10.2–10.3, ML-04 in the ticket graph, + and detailed design §§8.1–8.4, 8.8–8.9, 12, and 13. +- Compose the package registry, project plugin service, policy client, capability + registry, and Data Service adapter in Main; expose only bounded project + projections, never adapter internals, tokens, owner, prices, or raw policy. +- Expose exact GET/PUT `/api/coding/plugins` routes using a local project handle + resolved by `CodingProjectService` to real path and durable identity. Keep Data + Service configuration on existing typed routes and preserve IPC/loopback order. +- Wire enable/disable managed-input revision and adapter deactivation, plus + logout, project transition, identity-change-before-write, shutdown, and + backend-degraded behavior without deleting data. +- Test-first with one final implementation commit whose sole parent is the + exact base. Keep the worktree isolated and do not publish or claim a PR. + +## Project Context Loaded + +- Concurrent Task Gate passed: `check_project_docs.py` succeeded and + `task_context.py start`/`status --json` match this task ID, owner, + worktree, branch, feature mode, and base commit. +- Read the required entry files, active task record, project positioning/current + state/decision/system/architecture/domain/evidence/reflection/commitment/stale + indexes, MakeLore `AGENTS.md`, implementation spec §7.5 and §10.2–10.3, ML-04 + ticket, and detailed-design host/lifecycle/authority/error sections. +- Same-topic peer scopes are the client coordinator and completed ML-01–ML-03 + records. ML-03's exact product frontier is `fd891ff3bb87a29381a0a7006fb4618ec4fe144f`, + integrated by the coordinator as `a92cd904d33d4fa0b7c2413188186852a39eb6d4`. + ML-01 owns project-service files; no unresolved semantic conflict exists. +- Integrated project memory is older than this plugin implementation and remains + a last-integrated snapshot; frozen spec, coordinator task record, and exact + frontier control this task. Root `main` remains outside this worktree and is + not modified. + +## Plan + +1. Inspect the exact ML-03 product seams and existing route/composition/lifecycle + tests without changing files; map the required Host DTO and lifecycle callbacks. +2. Add failing focused tests for exact routes, Main-only authority, composition, + projection/degraded inspect behavior, and lifecycle invalidation/deactivation. +3. Implement the smallest cohesive Host/composition/lifecycle changes within the + exclusive ownership boundary, preserving typed Data Service routes and route + dispatcher ordering. +4. Run owned focused and relevant regressions, typecheck, lint, build as required, + and diff/doc gates; fix only failures within owned scope. +5. Record exact outcome/evidence, run `check_doc_drift.py`, complete task_context, + and return a clean single commit to the coordinator. + +## Gate Result + +- Planning Gate: Passed on 2026-08-27. No unresolved ownership or semantic + conflict affects ML-04. + +## Verification + +- Test-first red evidence: the three new focused test files initially failed + because `handleCodingPluginRoutes` and `createCodingProjectPluginService` did + not exist. The first lifecycle composition assertion then exposed an invalid + test premise (durable project identity is immutable); it was corrected to the + supported legacy-identity resolution path before the final green run. +- ML-04 focused plus required Host/lifecycle regressions: + `coding-plugin-routes`, `coding-plugin-composition`, + `coding-plugin-lifecycle`, `coding-product-services`, + `data-service-server-registration`, `coding-core-routes`, + `coding-project-identity`, and `main-quit-lifecycle` — 8 files / 47 tests + passed with one worker. +- Policy/capability/Data Service/Pi/preview regressions — 12 files / 86 passed / + 2 staged-runtime-gated skips. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — 0 errors and the same 5 pre-existing warnings + in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `corepack pnpm run build:vite` — Renderer, Main, Preload, and release utility + builds passed; only existing dynamic-import and chunk-size warnings appeared. +- `git diff --check` — passed before documentation completion. + +## Outcome + +- Added the exact project-scoped GET/PUT Host routes and registered them in the + shared IPC/loopback dispatcher between coding project and conversation routes. + Requests accept only the local project handle plus the exact enable boolean; + path, durable identity, owner, token, policy, and price cannot be supplied. +- Added a bounded Main project-plugin projection service that resolves the local + handle through `CodingProjectService`, reads durable identity in Main, refreshes + and exactly joins verified policy with fixed package definitions, reports Skill + assignments, sanitizes backend projection, and isolates per-adapter inspect + failures. Existing typed Data Service routes remain the only configuration API. +- Composition now creates the project selection service, policy client, Data + Service adapter, capability registry, and Renderer projection. One + `configureCapabilityRegistry(...)` late-binding seam replaces production + `configureDataService(...)`; parent and child worker materialization receive the + same refresh-before-resolution capability registry. +- Selection changes mark managed resources stale and forget project worker + bindings. Disable invalidates preview and deactivates only the affected adapter; + project transition and identity change deactivate all session adapters, with + identity invalidation/deactivation completing before the atomic write. + Authentication cleanup deactivates the active project adapter. Shutdown + deactivates adapters before subagent/runtime shutdown. Cleanup failures are + isolated and never rewrite selection or delete cloud/local data. + +## Follow-ups + +- ML-05 may consume `CodingPluginProjectProjection` through the exact Host routes; + it must keep Data Service configuration on `/api/works/data-service/...`. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index e3c057e..fec1102 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -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(); }, diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 1cf7a9d..7e29944 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -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; } +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; + setEnabled( + localProjectId: string, + pluginId: string, + enabled: boolean, + ): Promise; + deactivate(projectPath: string, pluginId?: string): Promise; +} + +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; + projectPlugins: Pick; + policyClient: Pick; + 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 { + 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, diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index fee308e..7a99e61 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -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, diff --git a/electron/api/routes/coding-plugins.ts b/electron/api/routes/coding-plugins.ts new file mode 100644 index 0000000..41127f8 --- /dev/null +++ b/electron/api/routes/coding-plugins.ts @@ -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; + 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 { + 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(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; + } +} diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts new file mode 100644 index 0000000..ad50742 --- /dev/null +++ b/tests/unit/coding-plugin-composition.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment node + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createCodingProjectPluginService, +} from '../../electron/api/coding-product-services'; +import { createCodingProjectMetadata, createCodingProjectAgent } from '../../electron/coding-projects/project-config'; +import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry'; +import { createProjectPluginService } from '../../electron/coding-plugins/project-service'; +import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('coding plugin bounded product service', () => { + it('joins package and policy exactly while isolating adapter inspection failure', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-product-')); + roots.push(root); + await createCodingProjectMetadata(root, { + projectId: '11111111-1111-4111-8111-111111111111', + now: '2026-08-27T00:00:00.000Z', + }); + await createCodingProjectAgent(root, { + id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', + model: null, modelResolution: 'required', skillIds: ['data-service'], + responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] }, + }); + const projectPlugins = createProjectPluginService({ now: () => '2026-08-27T00:00:00.000Z' }); + await projectPlugins.enable(root, DATA_SERVICE_PLUGIN_DEFINITION.id); + const adapter: CodingPluginAdapter = { + pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id, + inspect: vi.fn().mockRejectedValue(new Error('secret upstream body')), + invoke: vi.fn(), + }; + const policyClient = { + refresh: vi.fn().mockResolvedValue(undefined), + getState: () => ({ + status: 'current' as const, + revision: 1, + lastVerifiedAt: 1, + catalog: { + schema_version: 1 as const, + catalog_version: 'catalog-a', + pricing_version: null, + plugins: [{ + plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id, + supported_contract_versions: [1], + status: 'active' as const, + capabilities: [{ + capability_id: 'data-service.documents', + operations: [{ + operation: 'get_document', + billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included quota' }, + }], + }], + }], + }, + }), + }; + const service = createCodingProjectPluginService({ + projects: { + getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }), + }, + projectPlugins, + policyClient, + adapters: [adapter], + definitions: [DATA_SERVICE_PLUGIN_DEFINITION], + }); + + const result = await service.list('local-a'); + + expect(policyClient.refresh).toHaveBeenCalledOnce(); + expect(result).toEqual({ + schemaVersion: 1, + project: { + localProjectId: 'local-a', + durableProjectId: '11111111-1111-4111-8111-111111111111', + }, + policyStatus: 'current', + items: [expect.objectContaining({ + id: DATA_SERVICE_PLUGIN_DEFINITION.id, + enabled: true, + state: 'degraded', + backend: { + status: 'degraded', code: 'plugin_backend_unavailable', + message: 'Plugin backend is temporarily unavailable', retryable: true, + }, + skills: [{ id: 'data-service', assignedAgentIds: ['builder'] }], + capabilities: [{ + id: 'data-service.documents', + operations: [{ + id: 'get_document', + billing: { mode: 'included', availability: 'available', notice: 'Included quota' }, + }], + }], + settingsSurface: 'data-service', + })], + }); + expect(JSON.stringify(result)).not.toMatch(/secret upstream body|projectPath|entitlement_scope/u); + }); + + it('deactivates only the requested adapter and isolates cleanup failures', async () => { + const firstDeactivate = vi.fn().mockRejectedValue(new Error('cleanup failed')); + const secondDeactivate = vi.fn().mockResolvedValue(undefined); + const service = createCodingProjectPluginService({ + projects: { getProject: vi.fn() }, + projectPlugins: { getEnabledPluginIds: vi.fn(), setEnabled: vi.fn() }, + policyClient: { refresh: vi.fn(), getState: vi.fn() }, + adapters: [ + { pluginId: 'plugin.first', inspect: vi.fn(), invoke: vi.fn(), deactivate: firstDeactivate }, + { pluginId: 'plugin.second', inspect: vi.fn(), invoke: vi.fn(), deactivate: secondDeactivate }, + ], + definitions: [], + }); + + await expect(service.deactivate('C:\\project', 'plugin.first')).resolves.toBeUndefined(); + expect(firstDeactivate).toHaveBeenCalledWith('C:\\project'); + expect(secondDeactivate).not.toHaveBeenCalled(); + + await expect(service.deactivate('C:\\project')).resolves.toBeUndefined(); + expect(firstDeactivate).toHaveBeenCalledTimes(2); + expect(secondDeactivate).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/coding-plugin-lifecycle.test.ts b/tests/unit/coding-plugin-lifecycle.test.ts new file mode 100644 index 0000000..8bc91f3 --- /dev/null +++ b/tests/unit/coding-plugin-lifecycle.test.ts @@ -0,0 +1,88 @@ +// @vitest-environment node + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { AgentBrowserModule } from '../../electron/agent-browser'; +import { createCodingComposition } from '../../electron/api/coding-composition'; +import { createProjectPluginService } from '../../electron/coding-plugins/project-service'; +import { readCodingProjectConfigV2 } from '../../electron/coding-projects/project-config'; +import { + createCodingProjectStore, + createLocalCodingProject, + createMemoryCodingProjectStorage, +} from '../../electron/coding-projects/project-store'; +import { DATA_SERVICE_PLUGIN_ID } from '../../shared/coding-plugins'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('coding plugin lifecycle wiring', () => { + it('marks managed inputs stale and invalidates/deactivates only after a real disable', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-lifecycle-')); + roots.push(root); + const stale = vi.fn(); + const invalidate = vi.fn(); + const deactivate = vi.fn().mockResolvedValue(undefined); + const service = createProjectPluginService({ + now: () => '2026-08-27T00:00:00.000Z', + onManagedInputsChanged: stale, + onAdapterDeactivated: async ({ projectPath }) => { + invalidate('project_deactivated'); + await deactivate(projectPath); + }, + }); + + await service.enable(root, DATA_SERVICE_PLUGIN_ID); + await service.disable(root, DATA_SERVICE_PLUGIN_ID); + await service.disable(root, DATA_SERVICE_PLUGIN_ID); + + expect(stale).toHaveBeenCalledTimes(2); + expect(stale.mock.calls.map(([event]) => event.revision)).toEqual([1, 2]); + expect(invalidate).toHaveBeenCalledOnce(); + expect(deactivate).toHaveBeenCalledOnce(); + expect(deactivate).toHaveBeenCalledWith(path.resolve(root)); + }); + + it('deactivates before identity write and before runtime shutdown', async () => { + const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-identity-')); + const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-runtime-')); + roots.push(projectPath, userDataDir); + const storage = createMemoryCodingProjectStorage(); + const store = createCodingProjectStore(storage); + const legacy = await createLocalCodingProject({ projectPath }, store); + const composition = createCodingComposition({ + storage, + projectStore: store, + browser: { close: vi.fn().mockResolvedValue(undefined) } as unknown as AgentBrowserModule, + paths: { + executablePath: process.execPath, + cliPath: path.join(projectPath, 'unused-cli.js'), + userDataDir, + bundledSkillsDir: path.resolve('resources/coding-skills'), + }, + }); + const secondId = '22222222-2222-4222-8222-222222222222'; + const order: string[] = []; + vi.spyOn(composition.plugins, 'deactivate').mockImplementation(async () => { + const snapshot = await readCodingProjectConfigV2(projectPath); + order.push(`deactivate:${snapshot.status === 'valid' ? snapshot.config.projectId : 'invalid'}`); + }); + vi.spyOn(composition.runtime, 'shutdown').mockImplementation(async () => { + order.push('runtime-shutdown'); + }); + + await composition.projects.resolveProjectIdentity(legacy.project.id, { + kind: 'bind', projectId: secondId, + }); + await composition.shutdown(); + + expect(order[0]).toBe('deactivate:undefined'); + expect(order).toContain(`deactivate:${secondId}`); + expect(order.at(-1)).toBe('runtime-shutdown'); + }); +}); diff --git a/tests/unit/coding-plugin-routes.test.ts b/tests/unit/coding-plugin-routes.test.ts new file mode 100644 index 0000000..5752a84 --- /dev/null +++ b/tests/unit/coding-plugin-routes.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node + +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import type { HostApiContext } from '../../electron/api/context'; +import { handleCodingPluginRoutes } from '../../electron/api/routes/coding-plugins'; + +function request(method: string, body?: unknown): IncomingMessage { + const req = new EventEmitter(); + const raw = body === undefined ? undefined : JSON.stringify(body); + Object.assign(req, { + method, + headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) }, + [Symbol.asyncIterator]: async function* () { + if (raw !== undefined) yield Buffer.from(raw); + }, + }); + return req as IncomingMessage; +} + +function response() { + const chunks: string[] = []; + const res = new EventEmitter(); + Object.assign(res, { + statusCode: 0, + setHeader: vi.fn(), + end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), + }); + return { + res: res as unknown as ServerResponse, + get status() { return (res as { statusCode: number }).statusCode; }, + json: () => JSON.parse(chunks.join('')) as Record, + }; +} + +async function invoke(ctx: HostApiContext, method: string, target: string, body?: unknown) { + const output = response(); + const handled = await handleCodingPluginRoutes( + request(method, body), + output.res, + new URL(`http://localhost${target}`), + ctx, + ); + return { handled, status: output.status, payload: output.json() }; +} + +describe('coding plugin Host routes', () => { + it('uses only the local project handle for GET and preserves the bounded projection', async () => { + const projection = { + schemaVersion: 1 as const, + project: { localProjectId: 'local-a', durableProjectId: 'durable-a' }, + policyStatus: 'current' as const, + items: [], + }; + const list = vi.fn().mockResolvedValue(projection); + const ctx = { codingProducts: { plugins: { list } } } as unknown as HostApiContext; + + const result = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a'); + + expect(result).toMatchObject({ handled: true, status: 200, payload: projection }); + expect(list).toHaveBeenCalledWith('local-a'); + expect(JSON.stringify(result.payload)).not.toMatch(/projectPath|owner|token|entitlement_scope/u); + }); + + it('accepts only the exact PUT body and never forwards authority fields', async () => { + const setEnabled = vi.fn().mockResolvedValue({ + schemaVersion: 1, + project: { localProjectId: 'local-a', durableProjectId: null }, + policyStatus: 'unavailable', + items: [], + }); + const ctx = { codingProducts: { plugins: { setEnabled } } } as unknown as HostApiContext; + + const accepted = await invoke(ctx, 'PUT', '/api/coding/plugins/makelore.data-service', { + projectId: 'local-a', enabled: true, + }); + const rejected = await invoke(ctx, 'PUT', '/api/coding/plugins/makelore.data-service', { + projectId: 'local-a', enabled: true, projectPath: 'C:\\untrusted', + }); + + expect(accepted.status).toBe(200); + expect(setEnabled).toHaveBeenCalledOnce(); + expect(setEnabled).toHaveBeenCalledWith('local-a', 'makelore.data-service', true); + expect(rejected).toMatchObject({ status: 400, payload: { success: false, code: 'plugin_request_invalid' } }); + }); + + it('rejects duplicate or unexpected query authority before calling Main services', async () => { + const list = vi.fn(); + const ctx = { codingProducts: { plugins: { list } } } as unknown as HostApiContext; + + const duplicate = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a&projectId=local-b'); + const authority = await invoke(ctx, 'GET', '/api/coding/plugins?projectId=local-a&durableProjectId=forged'); + + expect(duplicate.status).toBe(400); + expect(authority.status).toBe(400); + expect(list).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/data-service-server-registration.test.ts b/tests/unit/data-service-server-registration.test.ts index fdafc6a..2e7a49f 100644 --- a/tests/unit/data-service-server-registration.test.ts +++ b/tests/unit/data-service-server-registration.test.ts @@ -16,4 +16,20 @@ describe('Data Service Host API registration', () => { expect(worksIndex).toBeGreaterThan(dataServiceIndex); expect(source).not.toContain('handleDataServiceRoute = handleDataServiceRoutes'); }); + + it('registers project plugins in the shared coding dispatcher without replacing typed configuration', async () => { + const source = await readFile(resolve('electron/api/route-handlers.ts'), 'utf8'); + expect(source).toMatch( + /import\s+\{\s*handleCodingPluginRoutes\s*\}\s+from\s+['"]\.\/routes\/coding-plugins['"]/, + ); + const routeList = source.match(/hostApiRouteHandlers[^=]*=\s*\[([\s\S]*?)\];/); + const routes = routeList?.[1] ?? ''; + expect(routes.indexOf('handleCodingPluginRoutes')).toBeGreaterThan( + routes.indexOf('handleCodingProjectRoutes'), + ); + expect(routes.indexOf('handleCodingConversationRoutes')).toBeGreaterThan( + routes.indexOf('handleCodingPluginRoutes'), + ); + expect(routes.indexOf('handleDataServiceRoutes')).toBeGreaterThanOrEqual(0); + }); });