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

510 lines
17 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';
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;
plugins: CodingProjectPluginService;
projects: CodingProjectService;
conversations: CodingConversationService;
runtime: CodingConversationRuntime;
host: CodingProductHost;
sleep(reason: 'background_sleep' | 'auth_cleanup'): Promise<void>;
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;
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[];
}
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;
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.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 : [];
return {
id: definition.id,
version: definition.version,
contractVersion: definition.contractVersion,
requiresBackend: definition.requiresBackend,
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,
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[]>;
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 enabledPluginIds = options.getEnabledPluginIds
? await options.getEnabledPluginIds(project.path)
: [];
return await options.productTools.listSkills(
await selectedSkillIds(project.path, agentId),
enabledPluginIds,
);
},
async listCommands(conversationId) {
const context = await conversationContext(conversationId);
const enabledPluginIds = options.getEnabledPluginIds
? await options.getEnabledPluginIds(context.project.path)
: [];
const piCommands = options.listPiCommands
? normalizePiCommands(await options.listPiCommands(conversationId))
: [];
return await options.productTools.listCommands(context.skillIds, piCommands, enabledPluginIds);
},
async getChanges(conversationId) {
await conversationContext(conversationId);
return options.productTools.getChanges(conversationId);
},
};
}