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

@@ -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.210.3, ML-04 in the ticket graph,
and detailed design §§8.18.4, 8.88.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.210.3, ML-04
ticket, and detailed-design host/lifecycle/authority/error sections.
- Same-topic peer scopes are the client coordinator and completed ML-01ML-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.

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;
}
}

View File

@@ -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();
});
});

View File

@@ -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');
});
});

View File

@@ -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<string, unknown>,
};
}
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();
});
});

View File

@@ -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);
});
});