Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/agentHost/browser/agentHostSettingsShared.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { VSBuffer } from '../../../../base/common/buffer.js';
7
import { Emitter } from '../../../../base/common/event.js';
8
import { parse, ParseError } from '../../../../base/common/json.js';
9
import { IJSONSchema } from '../../../../base/common/jsonSchema.js';
10
import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
11
import { URI } from '../../../../base/common/uri.js';
12
import {
13
createFileSystemProviderError,
14
FileChangeType,
15
FilePermission,
16
FileSystemProviderCapabilities,
17
FileSystemProviderErrorCode,
18
FileType,
19
IFileChange,
20
IFileDeleteOptions,
21
IFileOverwriteOptions,
22
IFileSystemProviderWithFileReadWriteCapability,
23
IFileWriteOptions,
24
IStat,
25
IWatchOptions,
26
} from '../../../../platform/files/common/files.js';
27
import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js';
28
import { ILogService } from '../../../../platform/log/common/log.js';
29
import { Registry } from '../../../../platform/registry/common/platform.js';
30
import { ConfigPropertySchema, ConfigSchema } from '../../../../platform/agentHost/common/state/protocol/state.js';
31
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
32
import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
33
import { ISessionsProvider } from '../../../services/sessions/common/sessionsProvider.js';
34
35
// ============================================================================
36
// Shared helpers for agent-host config settings filesystem providers.
37
//
38
// Both the per-session (`agent-session-settings://...`) and the per-host
39
// (`agent-host-settings://...`) synthetic settings editors follow the same
40
// shape: they render a provider's config schema as a JSONC document, watch
41
// for config changes, and round-trip user edits through a
42
// `replace*Config` API. This module factors out that shared plumbing.
43
// ============================================================================
44
45
/**
46
* Minimal config shape shared by session ({@link ResolveSessionConfigResult})
47
* and root ({@link RootConfigState}) configuration.
48
*/
49
export interface IAgentHostConfigLike {
50
readonly schema: ConfigSchema;
51
readonly values: Record<string, unknown>;
52
}
53
54
/**
55
* Filter applied to schema properties to decide which ones surface in the
56
* editable document (and in the derived JSON schema).
57
*
58
* For session settings this filters to `sessionMutable && !readOnly`. For
59
* host settings all properties are editable, so the filter is a constant
60
* `true`.
61
*/
62
export type AgentHostConfigPropertyFilter = (key: string, schema: ConfigPropertySchema) => boolean;
63
64
/**
65
* Localized strings used to decorate the serialized JSONC document.
66
*/
67
export interface IAgentHostSettingsLocale {
68
/** Header comment line describing the document. */
69
readonly header: string;
70
/** Secondary hint comment describing save semantics. */
71
readonly saveHint: string;
72
/** Error message thrown when the document fails to parse as JSONC. */
73
readonly parseError: string;
74
/** Error message thrown when the parsed document is not a JSON object. */
75
readonly notObject: string;
76
}
77
78
/**
79
* Convert a config property schema (protocol shape) into an
80
* {@link IJSONSchema} suitable for registration with the JSON language
81
* service.
82
*/
83
export function convertPropertySchema(schema: ConfigPropertySchema): IJSONSchema {
84
const out: IJSONSchema = {
85
type: schema.type,
86
title: schema.title,
87
description: schema.description,
88
default: schema.default,
89
};
90
if (schema.enum && schema.enum.length > 0) {
91
out.enum = [...schema.enum];
92
if (schema.enumDescriptions && schema.enumDescriptions.length > 0) {
93
out.enumDescriptions = [...schema.enumDescriptions];
94
}
95
}
96
if (schema.type === 'array' && schema.items) {
97
out.items = convertPropertySchema(schema.items);
98
}
99
if (schema.type === 'object' && schema.properties) {
100
const properties: Record<string, IJSONSchema> = {};
101
for (const [key, value] of Object.entries(schema.properties)) {
102
properties[key] = convertPropertySchema(value);
103
}
104
out.properties = properties;
105
if (schema.required && schema.required.length > 0) {
106
out.required = [...schema.required];
107
}
108
}
109
return out;
110
}
111
112
/**
113
* Build a JSON schema describing the filtered properties of an agent-host
114
* config. Properties that pass {@link filter} are included; others are
115
* dropped. `required` entries are carried through when the referenced
116
* property survives the filter.
117
*/
118
export function buildAgentHostConfigJsonSchema(config: IAgentHostConfigLike, filter: AgentHostConfigPropertyFilter): IJSONSchema {
119
const properties: Record<string, IJSONSchema> = {};
120
const required: string[] = [];
121
for (const [key, schema] of Object.entries(config.schema.properties)) {
122
if (!filter(key, schema)) {
123
continue;
124
}
125
properties[key] = convertPropertySchema(schema);
126
if (config.schema.required?.includes(key)) {
127
required.push(key);
128
}
129
}
130
const result: IJSONSchema = {
131
type: 'object',
132
properties,
133
additionalProperties: true,
134
};
135
if (required.length > 0) {
136
result.required = required;
137
}
138
return result;
139
}
140
141
function buildHeaderComment(
142
locale: IAgentHostSettingsLocale,
143
props: readonly (readonly [string, ConfigPropertySchema])[] | undefined,
144
): string {
145
const lines: string[] = [];
146
lines.push(`// ${locale.header}`);
147
lines.push(`// ${locale.saveHint}`);
148
if (props && props.length > 0) {
149
lines.push('//');
150
for (const [key, schema] of props) {
151
const suffix = schema.enum && schema.enum.length > 0 ? ` (${schema.enum.join(' | ')})` : '';
152
const title = schema.title || key;
153
lines.push(`// ${key}: ${title}${suffix}`);
154
if (schema.description) {
155
lines.push(`// ${schema.description}`);
156
}
157
}
158
}
159
lines.push('');
160
return lines.join('\n');
161
}
162
163
/**
164
* Serialize the filtered config values into a commented, pretty-printed
165
* JSONC document.
166
*/
167
export function serializeAgentHostConfigDocument(
168
config: IAgentHostConfigLike | undefined,
169
filter: AgentHostConfigPropertyFilter,
170
locale: IAgentHostSettingsLocale,
171
): string {
172
if (!config) {
173
return `${buildHeaderComment(locale, undefined)}{}\n`;
174
}
175
176
const editableProps = Object.entries(config.schema.properties).filter(([key, schema]) => filter(key, schema));
177
const values: Record<string, unknown> = {};
178
for (const [key] of editableProps) {
179
if (config.values[key] !== undefined) {
180
values[key] = config.values[key];
181
}
182
}
183
184
return `${buildHeaderComment(locale, editableProps)}${JSON.stringify(values, null, 2)}\n`;
185
}
186
187
// ============================================================================
188
// AbstractAgentHostConfigFileSystemProvider
189
// ============================================================================
190
191
/**
192
* Base context shared by all settings filesystem providers. Subclasses
193
* extend with any additional state they need (e.g. a sessionId).
194
*/
195
export interface IAgentHostSettingsContext {
196
readonly providerId: string;
197
}
198
199
/**
200
* Abstract filesystem provider backing the synthetic agent-host settings
201
* JSONC editors. Subclasses supply scheme-specific URI parsing,
202
* config-fetching, change-watching, and replace-dispatch hooks; the base
203
* handles the boilerplate (`stat`/`readFile`/`writeFile`/error shapes).
204
*/
205
export abstract class AbstractAgentHostConfigFileSystemProvider<TContext extends IAgentHostSettingsContext> extends Disposable implements IFileSystemProviderWithFileReadWriteCapability {
206
207
readonly capabilities = FileSystemProviderCapabilities.FileReadWrite | FileSystemProviderCapabilities.PathCaseSensitive;
208
209
private readonly _onDidChangeCapabilities = this._register(new Emitter<void>());
210
readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event;
211
212
protected readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>());
213
readonly onDidChangeFile = this._onDidChangeFile.event;
214
215
constructor(
216
@ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,
217
@ILogService protected readonly _logService: ILogService,
218
) {
219
super();
220
}
221
222
// ---- Subclass hooks -----------------------------------------------------
223
224
/** URI scheme label used in error messages (e.g. `'agent-session-settings'`). */
225
protected abstract readonly _schemeLabel: string;
226
227
/** Log trace-tag (e.g. `'AgentSessionSettings'`). */
228
protected abstract readonly _traceTag: string;
229
230
/** Localized strings for the JSONC document and write-path errors. */
231
protected abstract readonly _locale: IAgentHostSettingsLocale;
232
233
/** Parse a URI of the subclass's scheme into a typed context. */
234
protected abstract _parseUri(resource: URI): TContext | undefined;
235
236
/** Render the current config for a context as a JSONC document. */
237
protected abstract _serialize(provider: IAgentHostSessionsProvider, ctx: TContext): string;
238
239
/**
240
* Subscribe for changes relevant to the given context. When a change is
241
* detected the subclass should invoke {@link fire}.
242
*/
243
protected abstract _watchChanges(provider: IAgentHostSessionsProvider, ctx: TContext, fire: () => void): IDisposable;
244
245
/** Register / refresh the JSON schema for the given context. */
246
protected abstract _ensureSchemaRegistered(provider: IAgentHostSessionsProvider, ctx: TContext): void;
247
248
/** Whether the backing config is currently available. */
249
protected abstract _hasConfig(provider: IAgentHostSessionsProvider, ctx: TContext): boolean;
250
251
/** Dispatch a replace write of the parsed JSONC document. */
252
protected abstract _replaceConfig(provider: IAgentHostSessionsProvider, ctx: TContext, values: Record<string, unknown>): Promise<void>;
253
254
/**
255
* Build a short human-readable description of `ctx` for log messages
256
* when a write is ignored due to missing config (e.g. a session id).
257
*/
258
protected abstract _describeForTrace(ctx: TContext): string;
259
260
// ---- IFileSystemProvider ------------------------------------------------
261
262
watch(resource: URI, _opts: IWatchOptions): IDisposable {
263
const parsed = this._parseUri(resource);
264
if (!parsed) {
265
return Disposable.None;
266
}
267
const provider = this._lookupProvider(parsed.providerId);
268
if (!provider) {
269
return Disposable.None;
270
}
271
return this._watchChanges(provider, parsed, () => {
272
this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
273
});
274
}
275
276
async stat(resource: URI): Promise<IStat> {
277
const { provider, ctx } = this._resolveOrThrow(resource);
278
const content = this._serialize(provider, ctx);
279
return {
280
type: FileType.File,
281
ctime: 0,
282
mtime: 0,
283
size: VSBuffer.fromString(content).byteLength,
284
permissions: 0 as FilePermission,
285
};
286
}
287
288
async readdir(): Promise<[string, FileType][]> {
289
throw createFileSystemProviderError('readdir not supported', FileSystemProviderErrorCode.NoPermissions);
290
}
291
292
async readFile(resource: URI): Promise<Uint8Array> {
293
const { provider, ctx } = this._resolveOrThrow(resource);
294
const content = this._serialize(provider, ctx);
295
296
// Register the JSON schema on demand the first time a settings file
297
// is read. The subclass keeps it in sync from then on.
298
this._ensureSchemaRegistered(provider, ctx);
299
300
return VSBuffer.fromString(content).buffer;
301
}
302
303
async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise<void> {
304
const { provider, ctx } = this._resolveOrThrow(resource);
305
306
const text = VSBuffer.wrap(content).toString();
307
const errors: ParseError[] = [];
308
const parsed_json = parse(text, errors);
309
if (errors.length > 0) {
310
throw createFileSystemProviderError(this._locale.parseError, FileSystemProviderErrorCode.Unavailable);
311
}
312
if (parsed_json === null || typeof parsed_json !== 'object' || Array.isArray(parsed_json)) {
313
throw createFileSystemProviderError(this._locale.notObject, FileSystemProviderErrorCode.Unavailable);
314
}
315
316
if (!this._hasConfig(provider, ctx)) {
317
this._logService.trace(`[${this._traceTag}] No config state for ${this._describeForTrace(ctx)}; ignoring write.`);
318
this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
319
return;
320
}
321
322
await this._replaceConfig(provider, ctx, parsed_json as Record<string, unknown>);
323
324
this._onDidChangeFile.fire([{ type: FileChangeType.UPDATED, resource }]);
325
}
326
327
async mkdir(): Promise<void> {
328
throw createFileSystemProviderError('mkdir not supported', FileSystemProviderErrorCode.NoPermissions);
329
}
330
331
async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> {
332
throw createFileSystemProviderError('delete not supported', FileSystemProviderErrorCode.NoPermissions);
333
}
334
335
async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> {
336
throw createFileSystemProviderError('rename not supported', FileSystemProviderErrorCode.NoPermissions);
337
}
338
339
// ---- Helpers ------------------------------------------------------------
340
341
protected _lookupProvider(providerId: string): IAgentHostSessionsProvider | undefined {
342
const provider = this._sessionsProvidersService.getProvider(providerId);
343
if (!provider || !isAgentHostProvider(provider)) {
344
return undefined;
345
}
346
return provider;
347
}
348
349
private _resolveOrThrow(resource: URI): { provider: IAgentHostSessionsProvider; ctx: TContext } {
350
const ctx = this._parseUri(resource);
351
if (!ctx) {
352
throw createFileSystemProviderError(`Invalid ${this._schemeLabel} URI: ${resource.toString()}`, FileSystemProviderErrorCode.FileNotFound);
353
}
354
const provider = this._lookupProvider(ctx.providerId);
355
if (!provider) {
356
throw createFileSystemProviderError(`Unknown agent host provider: ${ctx.providerId}`, FileSystemProviderErrorCode.FileNotFound);
357
}
358
return { provider, ctx };
359
}
360
}
361
362
// ============================================================================
363
// AbstractAgentHostConfigSchemaRegistrar
364
// ============================================================================
365
366
/**
367
* Abstract base for the schema registrars that keep JSON schemas registered
368
* on the {@link IJSONContributionRegistry} for the synthetic settings
369
* editors. Subclasses plumb per-provider subscriptions and the target-type
370
* that identifies what a schema belongs to (an `ISession` for the session
371
* editor, an `IAgentHostSessionsProvider` for the host editor).
372
*
373
* Registration is lazy — {@link ensureRegistered} is called by the
374
* filesystem provider when a settings file is first read. Once registered,
375
* the schema is kept in sync via the subclass's change subscription until
376
* the provider is removed.
377
*/
378
export abstract class AbstractAgentHostConfigSchemaRegistrar<TTarget> extends Disposable {
379
380
private readonly _schemaRegistry = Registry.as<IJSONContributionRegistry>(JSONExtensions.JSONContribution);
381
382
/** Per-provider subscriptions. */
383
private readonly _providerSubscriptions = this._register(new DisposableMap<string /* providerId */>());
384
385
/** Per-target registered-schema disposables, keyed by the settings URI string. */
386
private readonly _targetSchemas = this._register(new DisposableMap<string /* settingsUri */>());
387
388
/**
389
* Tracks the {@link ConfigSchema} identity last used to register a schema
390
* for a given settings URI so we can skip re-registration when only
391
* values have changed.
392
*/
393
private readonly _lastSchemaIdentity = new Map<string /* settingsUri */, ConfigSchema>();
394
395
constructor(
396
@ISessionsProvidersService protected readonly _sessionsProvidersService: ISessionsProvidersService,
397
) {
398
super();
399
400
for (const provider of this._sessionsProvidersService.getProviders()) {
401
this._onProviderAdded(provider);
402
}
403
this._register(this._sessionsProvidersService.onDidChangeProviders(e => {
404
for (const provider of e.added) {
405
this._onProviderAdded(provider);
406
}
407
for (const provider of e.removed) {
408
this._providerSubscriptions.deleteAndDispose(provider.id);
409
}
410
}));
411
}
412
413
// ---- Subclass hooks -----------------------------------------------------
414
415
/** Stringified URI identifying the settings document for a target. */
416
protected abstract _settingsUri(target: TTarget): string;
417
418
/** `vscode://schemas/...` schema id used for JSON language service registration. */
419
protected abstract _schemaId(target: TTarget): string;
420
421
/** Fetch the backing config for a target. Returns `undefined` when none yet. */
422
protected abstract _getConfig(provider: IAgentHostSessionsProvider, target: TTarget): IAgentHostConfigLike | undefined;
423
424
/** Filter applied to schema properties when building the JSON schema. */
425
protected abstract _propertyFilter(): AgentHostConfigPropertyFilter;
426
427
/** Enumerate the targets currently tracked on a provider (used for cleanup). */
428
protected abstract _targetsForProvider(provider: IAgentHostSessionsProvider): readonly TTarget[];
429
430
/**
431
* Subscribe to change signals from {@link provider}. The subclass should
432
* invoke {@link onChanged} when a tracked target's config changes and
433
* {@link onRemoved} when a tracked target disappears.
434
*/
435
protected abstract _observeProvider(
436
provider: IAgentHostSessionsProvider,
437
onChanged: (target: TTarget) => void,
438
onRemoved: (target: TTarget) => void,
439
): IDisposable;
440
441
// ---- Public API ---------------------------------------------------------
442
443
/**
444
* Ensures a JSON schema is registered for the given target. Safe to
445
* call repeatedly; a no-op when the cached schema identity matches.
446
*/
447
ensureRegistered(provider: IAgentHostSessionsProvider, target: TTarget): void {
448
this._refreshSchema(provider, target);
449
}
450
451
// ---- Internal -----------------------------------------------------------
452
453
private _onProviderAdded(provider: ISessionsProvider): void {
454
if (!isAgentHostProvider(provider)) {
455
return;
456
}
457
const store = new DisposableStore();
458
459
store.add(this._observeProvider(
460
provider,
461
target => {
462
// Only refresh if we already have a registration; otherwise the
463
// next `readFile` will pick up the latest schema on demand.
464
if (!this._lastSchemaIdentity.has(this._settingsUri(target))) {
465
return;
466
}
467
this._refreshSchema(provider, target);
468
},
469
target => this._disposeSchemaForTarget(target),
470
));
471
472
// On provider disposal, drop all schemas registered for this provider.
473
store.add(toDisposable(() => {
474
for (const target of this._targetsForProvider(provider)) {
475
this._disposeSchemaForTarget(target);
476
}
477
}));
478
479
this._providerSubscriptions.set(provider.id, store);
480
}
481
482
private _refreshSchema(provider: IAgentHostSessionsProvider, target: TTarget): void {
483
const config = this._getConfig(provider, target);
484
if (!config) {
485
return;
486
}
487
const settingsUri = this._settingsUri(target);
488
const identity = config.schema;
489
if (this._lastSchemaIdentity.get(settingsUri) === identity) {
490
return;
491
}
492
493
const schema = buildAgentHostConfigJsonSchema(config, this._propertyFilter());
494
const schemaId = this._schemaId(target);
495
496
// Dispose any prior registration first, otherwise the old cleanup
497
// disposable would delete the freshly registered schema.
498
this._targetSchemas.deleteAndDispose(settingsUri);
499
500
const store = new DisposableStore();
501
this._schemaRegistry.registerSchema(schemaId, schema, store);
502
store.add(this._schemaRegistry.registerSchemaAssociation(schemaId, settingsUri));
503
store.add(toDisposable(() => this._lastSchemaIdentity.delete(settingsUri)));
504
505
this._targetSchemas.set(settingsUri, store);
506
this._lastSchemaIdentity.set(settingsUri, identity);
507
}
508
509
private _disposeSchemaForTarget(target: TTarget): void {
510
this._targetSchemas.deleteAndDispose(this._settingsUri(target));
511
}
512
}
513
514