feat(coding): compose plugin host lifecycle
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
130
electron/api/routes/coding-plugins.ts
Normal file
130
electron/api/routes/coding-plugins.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
130
tests/unit/coding-plugin-composition.test.ts
Normal file
130
tests/unit/coding-plugin-composition.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
88
tests/unit/coding-plugin-lifecycle.test.ts
Normal file
88
tests/unit/coding-plugin-lifecycle.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
99
tests/unit/coding-plugin-routes.test.ts
Normal file
99
tests/unit/coding-plugin-routes.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user