Path: blob/main/src/vs/platform/agentHost/node/agentPluginManager.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { VSBuffer } from '../../../base/common/buffer.js';6import { SequencerByKey } from '../../../base/common/async.js';7import { URI } from '../../../base/common/uri.js';8import { IFileService } from '../../files/common/files.js';9import { ILogService } from '../../log/common/log.js';10import { IAgentPluginManager, type ISyncedCustomization } from '../common/agentPluginManager.js';11import { CustomizationStatus, type CustomizationRef, type SessionCustomization } from '../common/state/sessionState.js';12import { toAgentClientUri } from '../common/agentClientUri.js';1314const DEFAULT_MAX_PLUGINS = 20;1516/** On-disk cache entry format. */17interface ICacheEntry {18readonly uri: string;19readonly nonce: string;20}2122/**23* Implementation of {@link IAgentPluginManager}.24*25* Syncs plugin directories to local storage under26* `{userDataPath}/agentPlugins/{key}/`. Uses a {@link SequencerByKey}27* per plugin URI so that concurrent syncs of the same plugin are28* serialized and cannot clobber each other.29*30* The nonce cache and LRU order are persisted to a JSON file in the31* base path so they survive process restarts.32*/33export class AgentPluginManager implements IAgentPluginManager {34declare readonly _serviceBrand: undefined;3536private readonly _basePath: URI;37private readonly _cachePath: URI;38private readonly _maxPlugins: number;3940/** Serializes concurrent sync operations per plugin URI. */41private readonly _sequencer = new SequencerByKey<string>();4243/** Nonces for plugins on disk, keyed by original customization URI string. */44private readonly _cachedNonces = new Map<string, string>();4546/** LRU order: most recently used original customization URI strings at the end. */47private readonly _lruOrder: string[] = [];4849/** Whether the on-disk cache has been loaded. */50private _cacheLoaded = false;5152constructor(53userDataPath: URI,54@IFileService private readonly _fileService: IFileService,55@ILogService private readonly _logService: ILogService,56maxPlugins: number = DEFAULT_MAX_PLUGINS,57) {58this._basePath = URI.joinPath(userDataPath, 'agentPlugins');59this._cachePath = URI.joinPath(this._basePath, 'cache.json');60this._maxPlugins = maxPlugins;61}6263async syncCustomizations(64clientId: string,65customizations: CustomizationRef[],66progress?: (status: SessionCustomization[]) => void,67): Promise<ISyncedCustomization[]> {68await this._ensureCacheLoaded();6970// Build initial loading status and fire it immediately via progress71const statuses: SessionCustomization[] = customizations.map(c => ({72customization: c,73enabled: true,74status: CustomizationStatus.Loading,75}));76progress?.([...statuses]);7778// Sync each customization in parallel, serialized per URI79const results = await Promise.all(customizations.map((ref, i) =>80this._sequencer.queue(ref.uri, async (): Promise<ISyncedCustomization> => {81try {82const pluginDir = await this._syncPlugin(clientId, ref);83statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Loaded };84progress?.([...statuses]);85return { customization: statuses[i], pluginDir };86} catch (err) {87const message = err instanceof Error ? err.message : String(err);88this._logService.error(`[AgentPluginManager] Failed to sync plugin ${ref.uri}: ${message}`);89statuses[i] = { customization: ref, enabled: true, status: CustomizationStatus.Error, statusMessage: message };90progress?.([...statuses]);91return { customization: statuses[i] };92}93})94));9596return results;97}9899// ---- plugin storage logic -----------------------------------------------100101/**102* Syncs a single plugin to local storage. Skips the copy when the103* nonce matches the cached value. Returns the local directory URI.104*/105private async _syncPlugin(clientId: string, ref: CustomizationRef): Promise<URI> {106const pluginUri = toAgentClientUri(URI.parse(ref.uri), clientId);107const key = this._keyForUri(ref.uri);108const destDir = URI.joinPath(this._basePath, key);109110// Nonce cache hit — skip copy111if (ref.nonce && this._cachedNonces.get(ref.uri) === ref.nonce) {112this._touchLru(ref.uri);113this._logService.trace(`[AgentPluginManager] Nonce match for ${ref.uri}, skipping copy`);114return destDir;115}116117this._logService.info(`[AgentPluginManager] Syncing plugin: ${ref.uri} → ${destDir.toString()}`);118119await this._fileService.copy(pluginUri, destDir, true);120121if (ref.nonce) {122this._cachedNonces.set(ref.uri, ref.nonce);123}124this._touchLru(ref.uri);125await this._evictIfNeeded();126await this._persistCache();127128return destDir;129}130131private _keyForUri(uri: string): string {132return uri.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 128);133}134135private _touchLru(uri: string): void {136const idx = this._lruOrder.indexOf(uri);137if (idx !== -1) {138this._lruOrder.splice(idx, 1);139}140this._lruOrder.push(uri);141}142143private async _evictIfNeeded(): Promise<void> {144while (this._lruOrder.length > this._maxPlugins) {145const evictUri = this._lruOrder.shift();146if (!evictUri) {147break;148}149this._cachedNonces.delete(evictUri);150const evictKey = this._keyForUri(evictUri);151const evictDir = URI.joinPath(this._basePath, evictKey);152this._logService.info(`[AgentPluginManager] Evicting plugin: ${evictUri}`);153try {154await this._fileService.del(evictDir, { recursive: true });155} catch (err) {156this._logService.warn(`[AgentPluginManager] Failed to evict plugin: ${evictUri}`, err);157}158}159}160161// ---- cache persistence --------------------------------------------------162163private async _ensureCacheLoaded(): Promise<void> {164if (this._cacheLoaded) {165return;166}167this._cacheLoaded = true;168169try {170if (!await this._fileService.exists(this._cachePath)) {171return;172}173const content = await this._fileService.readFile(this._cachePath);174const entries: ICacheEntry[] = JSON.parse(content.value.toString());175if (!Array.isArray(entries)) {176return;177}178179// Entries are stored in LRU order (oldest first)180for (const entry of entries) {181if (typeof entry.uri === 'string' && typeof entry.nonce === 'string') {182this._cachedNonces.set(entry.uri, entry.nonce);183this._lruOrder.push(entry.uri);184}185}186this._logService.trace(`[AgentPluginManager] Loaded ${entries.length} cache entries from disk`);187} catch (err) {188this._logService.warn('[AgentPluginManager] Failed to load cache from disk', err);189}190}191192private async _persistCache(): Promise<void> {193try {194// Write entries in LRU order (oldest first)195const entries: ICacheEntry[] = [];196for (const uri of this._lruOrder) {197const nonce = this._cachedNonces.get(uri);198if (nonce) {199entries.push({ uri, nonce });200}201}202await this._fileService.createFolder(this._basePath);203await this._fileService.writeFile(this._cachePath, VSBuffer.fromString(JSON.stringify(entries)));204} catch (err) {205this._logService.warn('[AgentPluginManager] Failed to persist cache to disk', err);206}207}208}209210211