Files
makelore/electron/api/coding-product-services.ts

730 lines
26 KiB
TypeScript

import type {
CodingProjectFileContent,
CodingProjectFileEntry,
CodingTextSearchResult,
ConversationChangesSnapshot,
ProductCodingCommand,
ProductCodingSkill,
ProductPiCommandInput,
} from '../../shared/coding-product-tools';
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
import { CodingProjectFileService } from '../coding-projects/project-files';
import {
CodingProjectServiceError,
type CodingProjectService,
} from '../coding-projects/project-service';
import type { CodingConversationService } from '../coding-runtime/conversation-service';
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 {
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';
import type {
CatalogPage,
CatalogQuery,
MarketplaceClient,
PluginDetail,
} from '../coding-plugins/marketplace-client';
import type {
InstallationSnapshot,
PluginPackageStore,
} from '../coding-plugins/package-store';
import type { MarketplaceLibrarySnapshot } from '../coding-plugins/account-plugin-cache';
import type { EffectivePluginResolver } from '../coding-plugins/effective-resolver';
export interface ActiveCodingProject {
id: string;
path: string;
}
export interface CodingProductHost {
fileStatus(): Promise<CodingProjectFileEntry[]>;
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
fileContent(path: string): Promise<CodingProjectFileContent>;
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
}
export interface CodingProductComposition {
attachments: CodingAttachmentStore;
dataService: DataServiceOperations;
previewDataSession?: PreviewDataSessionManager;
productTools: PiProductTools;
pluginMarketplace: CodingPluginMarketplaceService;
/** Alias retained for Main callers that refer to the Marketplace service directly. */
marketplace: CodingPluginMarketplaceService;
plugins: CodingProjectPluginService;
projects: CodingProjectService;
conversations: CodingConversationService;
runtime: CodingConversationRuntime;
host: CodingProductHost;
sleep(reason: 'background_sleep' | 'auth_cleanup'): Promise<void>;
shutdown(): Promise<void>;
}
export type PublicPluginInstallation = Readonly<{
status: InstallationSnapshot['status'];
pluginId: string;
releaseId?: string;
version?: string;
channel?: 'stable' | 'beta';
reason?: string;
}>;
export type PublicPluginLibrary = Readonly<{
library: MarketplaceLibrarySnapshot;
installations: readonly PublicPluginInstallation[];
}>;
/** Main-owned Marketplace facade. It never exposes package roots or manifests. */
export interface CodingPluginMarketplaceService {
readCatalog(input?: CatalogQuery): Promise<CatalogPage>;
readDetail(pluginId: string): Promise<PluginDetail>;
readLibrary(): Promise<PublicPluginLibrary>;
acquire(pluginId: string): Promise<PublicPluginLibrary>;
remove(pluginId: string): Promise<PublicPluginLibrary>;
install(pluginId: string): Promise<PublicPluginInstallation>;
installBeta(pluginId: string): Promise<PublicPluginInstallation>;
update(pluginId: string): Promise<PublicPluginInstallation>;
uninstall(pluginId: string): Promise<PublicPluginInstallation>;
}
export interface CreateCodingPluginMarketplaceServiceOptions {
marketplace: MarketplaceClient;
packageStore: PluginPackageStore;
clientVersion: string;
onChanged?(event: {
pluginId: string;
kind: 'acquire' | 'remove' | 'install' | 'update' | 'uninstall';
}): Promise<void> | void;
}
function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstallation {
return {
status: snapshot.status,
pluginId: snapshot.pluginId,
...(snapshot.releaseId ? { releaseId: snapshot.releaseId } : {}),
...(snapshot.version ? { version: snapshot.version } : {}),
...(snapshot.channel ? { channel: snapshot.channel } : {}),
...(snapshot.reason ? { reason: snapshot.reason } : {}),
};
}
async function publicLibrary(
library: MarketplaceLibrarySnapshot,
packageStore: Pick<PluginPackageStore, 'getInstalled'>,
): Promise<PublicPluginLibrary> {
const installations = await Promise.all(library.items.map(async ({ pluginId }) => {
try {
const record = await packageStore.getInstalled(pluginId);
return record ? {
status: record.unavailableReason ? 'unavailable' as const : 'installed' as const,
pluginId: record.pluginId,
releaseId: record.releaseId,
version: record.version,
...(record.channel === undefined ? {} : { channel: record.channel }),
...(record.unavailableReason ? { reason: record.unavailableReason } : {}),
} : null;
} catch (error) {
const candidate = error && typeof error === 'object' && 'code' in error
&& typeof error.code === 'string'
? error.code
: '';
const reason = /^[a-z][a-z0-9_]{0,63}$/u.test(candidate)
? candidate
: 'plugin_backend_unavailable';
return { status: 'unavailable' as const, pluginId, reason };
}
}));
return {
library,
installations: installations
.filter((installation): installation is NonNullable<typeof installation> => installation !== null)
.sort((left, right) => left.pluginId.localeCompare(right.pluginId)),
};
}
export function createCodingPluginMarketplaceService(
options: CreateCodingPluginMarketplaceServiceOptions,
): CodingPluginMarketplaceService {
const install = async (pluginId: string, kind: 'install' | 'update'): Promise<PublicPluginInstallation> => {
const snapshot = await options.packageStore.resolveAndInstall({
pluginId,
makeloreVersion: options.clientVersion,
channel: 'stable',
});
await options.onChanged?.({ pluginId, kind });
return publicInstallation(snapshot);
};
const installBeta = async (pluginId: string): Promise<PublicPluginInstallation> => {
const snapshot = await options.packageStore.resolveAndInstall({
pluginId,
makeloreVersion: options.clientVersion,
channel: 'beta',
explicitBeta: true,
});
await options.onChanged?.({ pluginId, kind: 'install' });
return publicInstallation(snapshot);
};
const uninstall = async (pluginId: string): Promise<PublicPluginInstallation> => {
const snapshot = await options.packageStore.uninstall(pluginId);
await options.onChanged?.({ pluginId, kind: 'uninstall' });
return publicInstallation(snapshot);
};
return {
readCatalog: (input = {}) => options.marketplace.readCatalog(input),
readDetail: (pluginId) => options.marketplace.readDetail(pluginId),
readLibrary: async () => publicLibrary(await options.marketplace.readLibrary(), options.packageStore),
acquire: async (pluginId) => {
const snapshot = await options.marketplace.acquire(pluginId);
await options.onChanged?.({ pluginId, kind: 'acquire' });
return publicLibrary(snapshot, options.packageStore);
},
remove: async (pluginId) => {
const snapshot = await options.marketplace.remove(pluginId);
await options.onChanged?.({ pluginId, kind: 'remove' });
return publicLibrary(snapshot, options.packageStore);
},
install: (pluginId) => install(pluginId, 'install'),
installBeta,
update: (pluginId) => install(pluginId, 'update'),
uninstall,
};
}
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;
unknownPluginIds: string[];
items: Array<{
id: string;
version: string;
contractVersion: number;
requiresBackend: boolean;
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;
tool: {
name: string;
label: string;
description: string;
mutation: 'read' | 'write' | 'destructive';
permissions: string[];
} | null;
}>;
}>;
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[];
getDefinitions?(): readonly CodingPluginDefinition[] | Promise<readonly CodingPluginDefinition[]>;
effectiveResolver?: EffectivePluginResolver;
}
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 adapters = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter]));
async function definitions(): Promise<readonly CodingPluginDefinition[]> {
const result = new Map(options.definitions.map((definition) => [definition.id, definition]));
for (const definition of options.getDefinitions ? await options.getDefinitions() : []) {
// Bundled definitions are code-owned and cannot be shadowed by an
// installed package reusing their identifier.
if (!result.has(definition.id)) result.set(definition.id, definition);
}
return [...result.values()];
}
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);
const pluginDefinitions = await definitions();
if (pluginDefinitions.some(({ requiresBackend }) => requiresBackend)) {
await options.policyClient.refresh();
}
const policy = options.policyClient.getState();
const [selection, config] = await Promise.all([
options.projectPlugins.getEnabledPluginIds(localProject.path),
readCodingProjectConfigV2(localProject.path),
]);
const assignedSkillIds = config.status === 'valid'
? [...new Set(config.config.agents
.filter((agent) => agent.enabled && !agent.archivedAt)
.flatMap(({ skillIds }) => skillIds))]
: [];
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: localProject.id,
projectPath: localProject.path,
assignedSkillIds,
role: 'parent',
})
: null;
const durableProjectId = config.status === 'valid' ? config.config.projectId ?? null : null;
const items = await Promise.all(pluginDefinitions.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))
: definition.requiresBackend ? degradedBackend() : { status: 'not_required' };
} catch {
backend = degradedBackend();
}
const pluginPolicy = policy.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id);
const policyAvailable = !definition.requiresBackend || Boolean(
pluginPolicy?.supported_contract_versions.includes(definition.contractVersion),
);
const capabilityIds = [...new Set(definition.operations.map(({ capabilityId }) => capabilityId))];
const capabilities = capabilityIds.flatMap((capabilityId) => {
const operations = definition.operations
.filter((candidate) => candidate.capabilityId === capabilityId)
.flatMap((candidate) => {
const joined = policyOperation(policy, definition, capabilityId, candidate.operation);
if (!joined) return [];
const tool = candidate.toolName
? definition.tools.find(({ name }) => name === candidate.toolName)
: undefined;
return [{
id: candidate.operation,
billing: publicBilling(joined.billing, policy),
tool: tool ? {
name: tool.name,
label: tool.label,
description: tool.description,
mutation: tool.mutation,
permissions: [...tool.permissions],
} : null,
}];
});
return operations.length > 0 ? [{ id: capabilityId, operations }] : [];
});
const agents = config.status === 'valid' ? config.config.agents : [];
const effectiveReason = effective?.unavailableReasons.find(({ pluginId }) => pluginId === definition.id);
const hasEffectiveSkill = effective?.effectiveSkillIds.some((skillId) => (
definition.skills.some(({ id }) => id === skillId)
)) ?? false;
let state = effectiveState({ enabled, policyAvailable, backend });
if (effective && effectiveReason?.code === 'project_disabled') state = 'disabled';
else if (effective && !hasEffectiveSkill && effectiveReason
&& ['account_required', 'library_required', 'library_unavailable', 'release_not_installed',
'release_invalid', 'client_incompatible', 'runtime_suspended', 'policy_unavailable',
'policy_unsupported']
.includes(effectiveReason.code)) state = 'unavailable';
return {
id: definition.id,
version: definition.version,
contractVersion: definition.contractVersion,
requiresBackend: definition.requiresBackend,
displayName: definition.displayName,
description: definition.description,
enabled,
state,
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,
unknownPluginIds: [...new Set(selection.filter((id) => (
!pluginDefinitions.some((definition) => definition.id === id)
)))].sort(),
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,
readonly code: string,
message: string,
) {
super(message);
}
}
export interface CodingProductHostOptions {
projects: Pick<CodingProjectService, 'getActiveProject' | 'findActiveConversation'>;
productTools: PiProductTools;
files?: CodingProjectFileService;
getEnabledPluginIds?(projectPath: string): Promise<readonly string[]>;
effectiveResolver?: EffectivePluginResolver;
listPiCommands?(conversationId: string): Promise<unknown>;
}
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
const candidates = Array.isArray(value)
? value
: Array.isArray(record?.commands)
? record.commands
: Array.isArray(record?.data)
? record.data
: [];
return candidates.flatMap((candidate) => {
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return [];
const command = candidate as Record<string, unknown>;
if (typeof command.name !== 'string') return [];
return [{
name: command.name,
...(typeof command.description === 'string' ? { description: command.description } : {}),
}];
});
}
export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost {
const files = options.files ?? new CodingProjectFileService();
async function activeProject(): Promise<ActiveCodingProject> {
const project = await options.projects.getActiveProject();
if (!project) {
throw new CodingProductHostError(
409,
'CODING_ACTIVE_PROJECT_REQUIRED',
'No active coding project is selected',
);
}
return project;
}
async function selectedSkillIds(
projectPath: string,
agentId?: string,
): Promise<readonly string[]> {
if (!agentId) return [];
const config = await readCodingProjectConfigV2(projectPath);
if (config.status !== 'valid') {
throw new CodingProductHostError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
const agent = config.config.agents.find((candidate) => (
candidate.id === agentId && candidate.enabled && !candidate.archivedAt
));
if (!agent) {
throw new CodingProductHostError(
404,
'CODING_AGENT_NOT_FOUND',
'Coding project Agent does not exist',
);
}
return agent.skillIds;
}
async function conversationContext(conversationId: string): Promise<{
project: ActiveCodingProject;
skillIds: readonly string[];
}> {
let context;
try {
context = await options.projects.findActiveConversation(conversationId);
} catch (error) {
if (error instanceof CodingProjectServiceError
&& (error.status === 404 || error.status === 409)) {
throw new CodingProductHostError(error.status, error.code, error.message);
}
throw error;
}
const { project, conversation } = context;
return {
project,
skillIds: await selectedSkillIds(project.path, conversation.agentId),
};
}
return {
async fileStatus() {
const project = await activeProject();
return await files.status(project.path);
},
async findFiles(query, limit) {
const project = await activeProject();
return await files.find(project.path, query, limit);
},
async fileContent(filePath) {
const project = await activeProject();
return await files.content(project.path, filePath);
},
async searchText(pattern) {
const project = await activeProject();
return await files.search(project.path, pattern);
},
async listSkills(agentId) {
const project = await activeProject();
const assignedSkillIds = await selectedSkillIds(project.path, agentId);
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: project.id,
projectPath: project.path,
assignedSkillIds,
role: 'parent',
})
: null;
// Keep the raw assignment for the Renderer projection so a known
// disabled Skill remains visible as selected/ineffective. The resolver
// still supplies the availability set; only the worker opener receives
// the effective subset.
const skillIds = assignedSkillIds;
const enabledPluginIds = effective
? effective.effectiveSkillIds
: options.getEnabledPluginIds
? await options.getEnabledPluginIds(project.path)
: [];
return await options.productTools.listSkills(
skillIds,
enabledPluginIds,
);
},
async listCommands(conversationId) {
const context = await conversationContext(conversationId);
const effective = options.effectiveResolver
? await options.effectiveResolver.resolve({
projectId: context.project.id,
projectPath: context.project.path,
assignedSkillIds: context.skillIds,
role: 'parent',
})
: null;
// Commands are projected from the assignment, while ProductTools marks
// only resolver-approved Skills effective. This preserves disabled
// assignment state without loading it into a worker.
const skillIds = context.skillIds;
const enabledPluginIds = effective
? effective.effectiveSkillIds
: options.getEnabledPluginIds
? await options.getEnabledPluginIds(context.project.path)
: [];
const piCommands = options.listPiCommands
? normalizePiCommands(await options.listPiCommands(conversationId))
: [];
return await options.productTools.listCommands(skillIds, piCommands, enabledPluginIds);
},
async getChanges(conversationId) {
await conversationContext(conversationId);
return options.productTools.getChanges(conversationId);
},
};
}