Path: blob/main/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts
13401 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 { Emitter } from '../../../../base/common/event.js';7import { parse, ParseError } from '../../../../base/common/json.js';8import { IJSONSchema } from '../../../../base/common/jsonSchema.js';9import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';10import { URI } from '../../../../base/common/uri.js';11import {12createFileSystemProviderError,13FileChangeType,14FilePermission,15FileSystemProviderCapabilities,16FileSystemProviderErrorCode,17FileType,18IFileChange,19IFileDeleteOptions,20IFileOverwriteOptions,21IFileSystemProviderWithFileReadWriteCapability,22IFileWriteOptions,23IStat,24IWatchOptions,25} from '../../../../platform/files/common/files.js';26import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';27import { ILogService } from '../../../../platform/log/common/log.js';28import { Registry } from '../../../../platform/registry/common/platform.js';29import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js';30import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';31import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';32import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';3334// ============================================================================35// Shared helpers for agent-host config settings filesystem providers.36//37// Both the per-session (`agent-session-settings://...`) and the per-host38// (`agent-host-settings://...`) synthetic settings editors follow the same39// shape: they render a provider's config schema as a JSONC document, watch40// for config changes, and round-trip user edits through a41// `replace*Config` API. This module factors out that shared plumbing.42// ============================================================================4344/**45* Minimal config shape shared by session ({@link ResolveSessionConfigResult})46* and root ({@link RootConfigState}) configuration.47*/48export interface IAgentHostConfigLike {49readonly schema: ConfigSchema;50readonly values: Record<string, unknown>;51}5253/**54* Filter applied to schema properties to decide which ones surface in the55* editable document (and in the derived JSON schema).56*57* For session settings this filters to `sessionMutable && !readOnly`. For58* host settings all properties are editable, so the filter is a constant59* `true`.60*/61export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean;6263/**64* Localized strings used to decorate the serialized JSONC document.65*/66export interface IAgentHostSettingsLocale {67/** Header comment line describing the document. */68readonly header: string;69/** Secondary hint comment describing save semantics. */70readonly saveHint: string;71/** Error message thrown when the document fails to parse as JSONC. */72readonly parseError: string;73/** Error message thrown when the parsed document is not a JSON object. */74readonly notObject: string;75}7677/**78* Convert a config property schema (protocol shape) into an79* {@link IJSONSchema} suitable for registration with the JSON language80* service.81*/82export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema {83const out: IJSONSchema = {84type: schema.type,85title: schema.title,86description: schema.description,87default: schema.default,88};89if (schema.enum && schema.enum.length > 0) {90out.enum = [...schema.enum];91if (schema.enumDescriptions && schema.enumDescriptions.length > 0) {92out.enumDescriptions = [...schema.enumDescriptions];93}94}95if (schema.type === 'array' && schema.items) {96out.items = convertPropertySchema(schema.items);97}98if (schema.type === 'object' && schema.properties) {99const properties: Record<string, IJSONSchema> = {};100for (const [key, value] of Object.entries(schema.properties)) {101properties[key] = convertPropertySchema(value);102}103out.properties = properties;104if (schema.required && schema.required.length > 0) {105out.required = [...schema.required];106}107}108return out;109}110111/**112* Build a JSON schema describing the filtered properties of an agent-host113* config. Properties that pass {@link filter} are included; others are114* dropped. `required` entries are carried through when the referenced115* property survives the filter.116*/117export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema {118const properties: Record<string, IJSONSchema> = {};119const required: string[] = [];120for (const [key, schema] of Object.entries(config.schema.properties)) {121if (!filter(key, schema)) {122continue;123}124properties[key] = convertPropertySchema(schema);125if (config.schema.required?.includes(key)) {126required.push(key);127}128}129const result: IJSONSchema = {130type: 'object',131properties,132additionalProperties: true,133};134if (required.length > 0) {135result.required = required;136}137return result;138}139140function buildHeaderComment(141locale: IAgentHostSettingsLocale,142props: readonly (readonly [string, ConfigPropertySchema])[] | undefined,143): string {144const lines: string[] = [];145lines.push(`// ${locale.header}`);146lines.push(`// ${locale.saveHint}`);147if (props && props.length > 0) {148lines.push('//');149for (const [key, schema] of props) {150const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : '';151const title = schema.title || key;152lines.push(`// ${key}: ${title}${suffix}`);153if (schema.description) {154lines.push(`// ${schema.description}`);155}156}157}158lines.push('');159return lines.join('\n');160}161162/**163* Serialize the filtered config values into a commented, pretty-printed164* JSONC document.165*/166export function serializeAgentHostConfigDocument(167config: IAgentHostConfigLike | undefined,168filter: AgentHostConfigPropertyFilter,169locale: IAgentHostSettingsLocale,170): string {171if (!config) {172return `${buildHeaderComment(locale, undefined)}{}\n`;173}174175const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema));176const values: Record<string, unknown> = {};177for (const [key] of editableProps) {178if (config.values[key] !== undefined) {179values[key] = config.values[key];180}181}182183return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`;184}185186// ============================================================================187// AbstractAgentHostConfigFileSystemProvider188// ============================================================================189190/**191* Base context shared by all settings filesystem providers. Subclasses192* extend with any additional state they need (e.g. a sessionId).193*/194export interface IAgentHostSettingsContext {195readonly providerId: string;196}197198/**199* Abstract filesystem provider backing the synthetic agent-host settings200* JSONC editors. Subclasses supply scheme-specific URI parsing,201* config-fetching, change-watching, and replace-dispatch hooks; the base202* handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes).203*/204export abstract class AbstractAgentHostConfigFileSystemProvider<TContext extends IAgentHostSettingsContext> extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {205206readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive;207208private readonly _onDidChangeCapabilities = this._register(new Emitter<void>());209readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event;210211protected readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());212readonly onDidChangeFile = this._onDidChangeFile.event;213214constructor(215@ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,216@ILogService protected readonly _logService: ILogService,217) {218super();219}220221// ---- Subclass hooks -----------------------------------------------------222223/** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */224protected abstract readonly _schemeLabel: string;225226/** Log trace-tag (e.g. `'AgentSessionSettings'`). */227protected abstract readonly _traceTag: string;228229/** Localized strings for the JSONC document and write-path errors. */230protected abstract readonly _locale: IAgentHostSettingsLocale;231232/** Parse a URI of the subclass's scheme into a typed context. */233protected abstract _parseUri(resource: URI): TContext | undefined;234235/** Render the current config for a context as a JSONC document. */236protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string;237238/**239* Subscribe for changes relevant to the given context. When a change is240* detected the subclass should invoke {@link fire}.241*/242protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable;243244/** Register / refresh the JSON schema for the given context. */245protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void;246247/** Whether the backing config is currently available. */248protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean;249250/** Dispatch a replace write of the parsed JSONC document. */251protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record<string, unknown>): Promise<void>;252253/**254* Build a short human-readable description of `ctx` for log messages255* when a write is ignored due to missing config (e.g. a session id).256*/257protected abstract _describeForTrace(ctx: TContext): string;258259// ---- IFileSystemProvider ------------------------------------------------260261watch(resource: URI, _opts: IWatchOptions): IDisposable {262const parsed = this._parseUri(resource);263if (!parsed) {264return Disposable.None;265}266const provider = this._lookupProvider(parsed.providerId);267if (!provider) {268return Disposable.None;269}270return this._watchChanges(provider, parsed, () => {271this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);272});273}274275async stat(resource: URI): Promise<IStat> {276const { provider, ctx } = this._resolveOrThrow(resource);277const content = this._serialize(provider, ctx);278return {279type: FileType.File,280ctime: 0,281mtime: 0,282size: VSBuffer.fromString(content).byteLength,283permissions: 0 as FilePermission,284};285}286287async readdir(): Promise<[string, FileType][]> {288throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions);289}290291async readFile(resource: URI): Promise<Uint8Array> {292const { provider, ctx } = this._resolveOrThrow(resource);293const content = this._serialize(provider, ctx);294295// Register the JSON schema on demand the first time a settings file296// is read. The subclass keeps it in sync from then on.297this._ensureSchemaRegistered(provider, ctx);298299return VSBuffer.fromString(content).buffer;300}301302async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise<void> {303const { provider, ctx } = this._resolveOrThrow(resource);304305const text = VSBuffer.wrap(content).toString();306const errors: ParseError[] = [];307const parsed_json = parse(text, errors);308if (errors.length > 0) {309throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable);310}311if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) {312throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable);313}314315if (!this._hasConfig(provider, ctx)) {316this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`);317this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);318return;319}320321await this._replaceConfig(provider, ctx, parsed_json as Record<string, unknown>);322323this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);324}325326async mkdir(): Promise<void> {327throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions);328}329330async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> {331throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions);332}333334async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> {335throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions);336}337338// ---- Helpers ------------------------------------------------------------339340protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined {341const provider = this._sessionsProvidersService.getProvider(providerId);342if (!provider || !isAgentHostProvider(provider)) {343return undefined;344}345return provider;346}347348private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } {349const ctx = this._parseUri(resource);350if (!ctx) {351throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);352}353const provider = this._lookupProvider(ctx.providerId);354if (!provider) {355throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound);356}357return { provider, ctx };358}359}360361// ============================================================================362// AbstractAgentHostConfigSchemaRegistrar363// ============================================================================364365/**366* Abstract base for the schema registrars that keep JSON schemas registered367* on the {@link IJSONContributionRegistry} for the synthetic settings368* editors. Subclasses plumb per-provider subscriptions and the target-type369* that identifies what a schema belongs to (an `ISession` for the session370* editor, an `IAgentHostSessionsProvider` for the host editor).371*372* Registration is lazy — {@link ensureRegistered} is called by the373* filesystem provider when a settings file is first read. Once registered,374* the schema is kept in sync via the subclass's change subscription until375* the provider is removed.376*/377export abstract class AbstractAgentHostConfigSchemaRegistrar<TTarget> extends Disposable {378379private readonly _schemaRegistry = Registry.as<IJSONContributionRegistry>(JSONExtensions.JSONContribution);380381/** Per-provider subscriptions. */382private readonly _providerSubscriptions = this._register(new DisposableMap<string /* providerId */>());383384/** Per-target registered-schema disposables, keyed by the settings URI string. */385private readonly _targetSchemas = this._register(new DisposableMap<string /* settingsUri */>());386387/**388* Tracks the {@link ConfigSchema} identity last used to register a schema389* for a given settings URI so we can skip re-registration when only390* values have changed.391*/392private readonly _lastSchemaIdentity = new Map<string /* settingsUri */, ConfigSchema>();393394constructor(395@ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,396) {397super();398399for (const provider of this._sessionsProvidersService.getProviders()) {400this._onProviderAdded(provider);401}402this._register(this._sessionsProvidersService.onDidChangeProviders(e => {403for (const provider of e.added) {404this._onProviderAdded(provider);405}406for (const provider of e.removed) {407this._providerSubscriptions.deleteAndDispose(provider.id);408}409}));410}411412// ---- Subclass hooks -----------------------------------------------------413414/** Stringified URI identifying the settings document for a target. */415protected abstract _settingsUri(target: TTarget): string;416417/** `vscode://schemas/...` schema id used for JSON language service registration. */418protected abstract _schemaId(target: TTarget): string;419420/** Fetch the backing config for a target. Returns `undefined` when none yet. */421protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined;422423/** Filter applied to schema properties when building the JSON schema. */424protected abstract _propertyFilter(): AgentHostConfigPropertyFilter;425426/** Enumerate the targets currently tracked on a provider (used for cleanup). */427protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[];428429/**430* Subscribe to change signals from {@link provider}. The subclass should431* invoke {@link onChanged} when a tracked target's config changes and432* {@link onRemoved} when a tracked target disappears.433*/434protected abstract _observeProvider(435provider: IAgentHostSessionsProvider,436onChanged: (target: TTarget) => void,437onRemoved: (target: TTarget) => void,438): IDisposable;439440// ---- Public API ---------------------------------------------------------441442/**443* Ensures a JSON schema is registered for the given target. Safe to444* call repeatedly; a no-op when the cached schema identity matches.445*/446ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void {447this._refreshSchema(provider, target);448}449450// ---- Internal -----------------------------------------------------------451452private _onProviderAdded(provider: ISessionsProvider): void {453if (!isAgentHostProvider(provider)) {454return;455}456const store = new DisposableStore();457458store.add(this._observeProvider(459provider,460target => {461// Only refresh if we already have a registration; otherwise the462// next `readFile` will pick up the latest schema on demand.463if (!this._lastSchemaIdentity.has(this._settingsUri(target))) {464return;465}466this._refreshSchema(provider, target);467},468target => this._disposeSchemaForTarget(target),469));470471// On provider disposal, drop all schemas registered for this provider.472store.add(toDisposable(() => {473for (const target of this._targetsForProvider(provider)) {474this._disposeSchemaForTarget(target);475}476}));477478this._providerSubscriptions.set(provider.id, store);479}480481private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void {482const config = this._getConfig(provider, target);483if (!config) {484return;485}486const settingsUri = this._settingsUri(target);487const identity = config.schema;488if (this._lastSchemaIdentity.get(settingsUri) === identity) {489return;490}491492const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter());493const schemaId = this._schemaId(target);494495// Dispose any prior registration first, otherwise the old cleanup496// disposable would delete the freshly registered schema.497this._targetSchemas.deleteAndDispose(settingsUri);498499const store = new DisposableStore();500this._schemaRegistry.registerSchema(schemaId, schema, store);501store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri));502store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri)));503504this._targetSchemas.set(settingsUri, store);505this._lastSchemaIdentity.set(settingsUri, identity);506}507508private _disposeSchemaForTarget(target: TTarget): void {509this._targetSchemas.deleteAndDispose(this._settingsUri(target));510}511}512513514