diff --git a/product.json b/product.json index 20c5a45fcc63..598a0b28f592 100644 --- a/product.json +++ b/product.json @@ -5,6 +5,7 @@ "version": "", "positronVersion": "2026.08.0", "positronBuildNumber": 0, + "positronLlmsDocsUrl": "https://cdn.posit.co/positron/releases/docs", "applicationName": "positron", "dataFolderName": ".positron", "sharedDataFolderName": ".positron-shared", diff --git a/src/positron-dts/positron.d.ts b/src/positron-dts/positron.d.ts index 4b552d1ddd12..d104a7716ee9 100644 --- a/src/positron-dts/positron.d.ts +++ b/src/positron-dts/positron.d.ts @@ -3467,6 +3467,53 @@ declare module 'positron' { ): Thenable; } + /** + * Access to Positron product documentation cached on disk. + */ + namespace docs { + /** + * A bundle of Positron documentation available on the extension host's + * local filesystem. + */ + export interface LocalDocs { + /** Absolute path of the extracted bundle root, on the extension host's filesystem. */ + readonly path: string; + + /** Bundle format version. Currently 1. */ + readonly schema: number; + + /** Docs version this bundle was generated from, e.g. '2026.05.0-179'. */ + readonly version: string; + + /** 'positron' or 'workbench'. */ + readonly profile: string; + + /** Base URL for building a citable web link to a page in this bundle. */ + readonly docsBaseUrl: string; + + /** True when the bundle matches the running build exactly. */ + readonly isExactMatch: boolean; + } + + /** + * Get the locally cached Positron documentation, downloading it if it is + * not present yet. + * + * Safe to call per docs need: a successful result is cached in process, + * and concurrent calls join a single in-flight download rather than + * starting several. Waits at most 10 seconds for an in-flight download; + * on timeout the download continues in the background and is available + * to the next call. + * + * Resolves to `undefined` when there are no local docs, which means the + * caller should fall back to fetching documentation from the web. That + * is the only meaning of `undefined`. + * + * @returns A Thenable resolving to the local docs, or undefined. + */ + export function getLocalDocs(): Thenable; + } + /** * Experimental AI features. */ diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 2c7416bc4e1c..018f52cb6ae0 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -78,6 +78,13 @@ export interface IProductConfiguration { /** The Positron build number; unique within the Positron version */ readonly positronBuildNumber: number; + /** + * Base URL for the slim LLM docs bundles the AI assistant reads from disk. + * Overridden at runtime by the POSITRON_LLMS_DOCS_URL environment variable. + * Distinct from the docs *website* URL, which is a separate knob. + */ + readonly positronLlmsDocsUrl?: string; + /** The linux package type (DEB or RPM) */ readonly packageType?: string; // --- End Positron --- diff --git a/src/vs/platform/positronDocs/common/positronDocsCache.ts b/src/vs/platform/positronDocs/common/positronDocsCache.ts index 5e0e531a95a9..7ea2daba2409 100644 --- a/src/vs/platform/positronDocs/common/positronDocsCache.ts +++ b/src/vs/platform/positronDocs/common/positronDocsCache.ts @@ -113,6 +113,21 @@ export class PositronDocsCache { this._generation++; } + /** + * The cached bundle, if any, without touching the network. + * + * Used when a caller has stopped waiting for an in-flight fetch: the + * cache-present rule says a valid cache is served regardless, and awaiting + * `ensure()` would defeat the point of the timeout. + */ + async peek(request: IDocsBundleRequest): Promise { + if (this._attempted) { + return this._result; + } + const exactVersion = resolveBundleRequest(request).exact.version; + return await this._readCached(await this._readState(), exactVersion); + } + private async _ensureOnce(request: IDocsBundleRequest): Promise { const resolved = resolveBundleRequest(request); const state = await this._readState(); diff --git a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts new file mode 100644 index 000000000000..d1fd5ab2132b --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDocsBundleRequest } from './positronDocsBundle.js'; +import { IDocsLogger, ILocalDocs } from './positronDocsIO.js'; + +// Matches the cache module's prefix so one grep covers the whole feature. +const LOG_PREFIX = '[llm-docs]'; + +/** The slice of PositronDocsCache the triggers need. */ +export interface IDocsCacheLike { + ensure(request: IDocsBundleRequest): Promise; + peek(request: IDocsBundleRequest): Promise; + invalidate(): void; +} + +export interface IPositronDocsTriggersOptions { + readonly cache: IDocsCacheLike; + readonly request: IDocsBundleRequest; + readonly logger: IDocsLogger; + /** Read live: ai.enabled is WINDOW-scoped and toggles without a reload. */ + readonly isAiEnabled: () => Promise; + /** How long getLocalDocs() waits for an in-flight fetch. */ + readonly waitMs: number; + /** Injected so tests control the timeout without fake timers. */ + readonly delay: (ms: number) => Promise; +} + +/** Race outcome, tagged so a resolved-but-undefined fetch stays distinguishable. */ +type RaceOutcome = + | { readonly timedOut: false; readonly docs: ILocalDocs | undefined } + | { readonly timedOut: true }; + +/** + * The three entry points into one operation. + * + * Launch and config-flip are fire-and-forget with no timeout, since nothing is + * waiting on them. Only `getLocalDocs()` is bounded, because a slow link must + * not stall an assistant response. + */ +export class PositronDocsTriggers { + + constructor(private readonly _options: IPositronDocsTriggersOptions) { } + + /** Launch trigger, and the tail of a config flip. Never throws. */ + async runBackgroundFetch(): Promise { + if (!await this._options.isAiEnabled()) { + this._options.logger.info(`${LOG_PREFIX} ai.enabled is off; not fetching docs`); + return; + } + try { + await this._options.cache.ensure(this._options.request); + } catch (error) { + // A background trigger has no caller to surface this to, and a docs + // download failing is not worth interrupting anyone over. + this._options.logger.warn(`${LOG_PREFIX} background docs fetch failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** ai.enabled false-to-true: the one in-session re-attempt the design allows. */ + async onAiEnabledFlippedTrue(): Promise { + this._options.cache.invalidate(); + await this.runBackgroundFetch(); + } + + /** + * First-need trigger. Starts the operation if idle, joins it if in flight, + * or returns the completed result. + */ + async getLocalDocs(): Promise { + const { cache, delay, logger, request, waitMs } = this._options; + if (!await this._options.isAiEnabled()) { + // Logged, not silent: from the caller's side this is indistinguishable + // from "no docs on disk", and the reason is the first thing anyone + // asking "why is the assistant using web docs?" needs to see. + logger.info(`${LOG_PREFIX} ai.enabled is off; not serving local docs`); + return undefined; + } + + const fetching: Promise = cache.ensure(request) + .then(docs => ({ timedOut: false as const, docs })) + .catch(error => { + logger.warn(`${LOG_PREFIX} docs fetch failed: ${error instanceof Error ? error.message : String(error)}`); + return { timedOut: false as const, docs: undefined }; + }); + const timingOut: Promise = delay(waitMs).then(() => ({ timedOut: true as const })); + + const winner = await Promise.race([fetching, timingOut]); + if (!winner.timedOut) { + return winner.docs; + } + + // The download continues in the background and is available to the next + // call; only this caller stops waiting. The cache-present rule still + // applies, so hand back whatever is already on disk. + logger.info(`${LOG_PREFIX} local docs not ready within ${waitMs}ms; continuing in the background`); + return await cache.peek(request); + } +} diff --git a/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts index ca630954850d..ce2733336d0e 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts @@ -501,6 +501,54 @@ describe('PositronDocsCache: a cache installed mid-attempt', () => { }); }); +describe('PositronDocsCache: peek', () => { + it('serves the installed bundle off disk while a fetch is still in flight', async () => { + const ctx = setup(); + ctx.publish(LATEST_ZIP, payload('2026.04.0-100'), 'etag-april'); + await ctx.cache.ensure(request({ quality: 'dailies' })); + + // The next session, with a download that never completes. This is the + // bounded-wait path: getLocalDocs() stops waiting and peeks, and the + // cache-present rule says the bundle already on disk is still served. + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + const next = new PositronDocsCache({ + rootPath: ROOT, files: ctx.files, archive: ctx.archive, logger: ctx.logger, + now: () => 2_000_000, newId: () => 'peek', + http: { get: async () => { await held; throw new Error('ENOTFOUND'); }, head: url => ctx.http.head(url) }, + }); + const pending = next.ensure(request({ quality: 'dailies' })); + + const peeked = await next.peek(request({ quality: 'dailies' })); + + expect(peeked?.version).toBe('2026.04.0-100'); + expect(peeked?.path).toBe(`${ROOT}/2026.04.0-100`); + + release(); + await pending; + }); + + it('returns undefined on a cold cache', async () => { + const ctx = setup(); + expect(await ctx.cache.peek(request())).toBeUndefined(); + }); + + it('returns the completed result once an attempt has finished', async () => { + const ctx = setup(); + ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); + await ctx.cache.ensure(request()); + + // The session's answer is fixed after ensure(), so peek hands back the + // memoized result rather than re-validating the directory. Deleting the + // directory is what tells the two branches apart. + await ctx.files.delete(`${ROOT}/2026.05.0-179`); + + expect((await ctx.cache.peek(request()))?.version).toBe('2026.05.0-179'); + // A fresh cache has nothing memoized, so it reads the truth on disk. + expect(await ctx.makeCache().peek(request())).toBeUndefined(); + }); +}); + describe('PositronDocsCache: single flight', () => { it('joins two concurrent calls into one download', async () => { const ctx = setup(); diff --git a/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts new file mode 100644 index 000000000000..20ab48106e93 --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { IDocsBundleRequest } from '../../common/positronDocsBundle.js'; +import { ILocalDocs } from '../../common/positronDocsIO.js'; +import { IDocsCacheLike, PositronDocsTriggers } from '../../common/positronDocsTriggers.js'; +import { recordingLogger } from './fakes.js'; + +const REQUEST: IDocsBundleRequest = { + quality: 'dailies', positronVersion: '2026.05.0', positronBuildNumber: 179, + profile: 'positron', baseUrl: 'https://cdn.posit.co/positron/releases/docs', +}; + +const DOCS: ILocalDocs = { + path: '/cache/2026.05.0-179', schema: 1, version: '2026.05.0-179', + profile: 'positron', docsBaseUrl: 'https://positron.posit.co/', isExactMatch: true, +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} + +function setup(options: { aiEnabled?: boolean; peeked?: ILocalDocs } = {}) { + const ensureGate = deferred(); + const timeoutGate = deferred(); + const ensure = vi.fn(() => ensureGate.promise); + const peek = vi.fn(async () => options.peeked); + const invalidate = vi.fn(); + const cache: IDocsCacheLike = { ensure, peek, invalidate }; + const logger = recordingLogger(); + const triggers = new PositronDocsTriggers({ + cache, request: REQUEST, logger, + isAiEnabled: async () => options.aiEnabled ?? true, + waitMs: 10_000, + delay: () => timeoutGate.promise, + }); + return { triggers, ensure, peek, invalidate, logger, ensureGate, timeoutGate }; +} + +/** + * Triggers whose fetch always rejects, over a cold cache. The timeout never + * fires, so the rejection is what settles the race in `getLocalDocs`. + */ +function rejectingTriggers(logger: ReturnType, message: string) { + return new PositronDocsTriggers({ + cache: { + ensure: async () => { throw new Error(message); }, + peek: async () => undefined, + invalidate: vi.fn(), + }, + request: REQUEST, logger, + isAiEnabled: async () => true, + waitMs: 10_000, + delay: () => new Promise(() => { }), + }); +} + +describe('PositronDocsTriggers: ai.enabled gating', () => { + it('does not fetch on launch when ai.enabled is false', async () => { + const ctx = setup({ aiEnabled: false }); + await ctx.triggers.runBackgroundFetch(); + expect(ctx.ensure).not.toHaveBeenCalled(); + }); + + it('returns undefined from getLocalDocs without touching the cache when ai.enabled is false', async () => { + const ctx = setup({ aiEnabled: false }); + expect(await ctx.triggers.getLocalDocs()).toBeUndefined(); + expect(ctx.ensure).not.toHaveBeenCalled(); + expect(ctx.peek).not.toHaveBeenCalled(); + // Undefined here looks identical to "no docs on disk" from the caller's + // side, so the reason has to be in the log. + expect(ctx.logger.infos.join('\n')).toContain('ai.enabled is off; not serving local docs'); + }); + + it('fetches on launch when ai.enabled is true', async () => { + const ctx = setup(); + const running = ctx.triggers.runBackgroundFetch(); + ctx.ensureGate.resolve(DOCS); + await running; + expect(ctx.ensure).toHaveBeenCalledTimes(1); + }); + + it('invalidates and refetches when ai.enabled flips true', async () => { + const ctx = setup(); + const running = ctx.triggers.onAiEnabledFlippedTrue(); + ctx.ensureGate.resolve(DOCS); + await running; + expect(ctx.invalidate).toHaveBeenCalledTimes(1); + expect(ctx.ensure).toHaveBeenCalledTimes(1); + }); +}); + +describe('PositronDocsTriggers: joining and the bounded wait', () => { + // The single-flight itself lives in PositronDocsCache (covered by + // positronDocsCache.vitest.ts). What matters here is that the triggers + // delegate every caller to it rather than keeping their own state. + it('serves every concurrent caller from the same delegated fetch', async () => { + const ctx = setup(); + const background = ctx.triggers.runBackgroundFetch(); + const first = ctx.triggers.getLocalDocs(); + const second = ctx.triggers.getLocalDocs(); + ctx.ensureGate.resolve(DOCS); + + expect(await first).toEqual(DOCS); + expect(await second).toEqual(DOCS); + await background; + // Each caller went straight to the cache with the same request, and none + // of them short-circuited or held a private copy of the result. + expect(ctx.ensure.mock.calls).toEqual([[REQUEST], [REQUEST], [REQUEST]]); + }); + + it('returns the existing cached bundle on timeout, and does not cancel the fetch', async () => { + const ctx = setup({ peeked: DOCS }); + const pending = ctx.triggers.getLocalDocs(); + ctx.timeoutGate.resolve(); + + expect(await pending).toEqual(DOCS); + expect(ctx.peek).toHaveBeenCalledTimes(1); + expect(ctx.logger.infos.join('\n')).toContain('continuing in the background'); + + // The download was never cancelled; only the caller stopped waiting. + ctx.ensureGate.resolve(DOCS); + expect(await ctx.triggers.getLocalDocs()).toEqual(DOCS); + }); + + it('returns undefined on timeout with a cold cache', async () => { + const ctx = setup({ peeked: undefined }); + const pending = ctx.triggers.getLocalDocs(); + ctx.timeoutGate.resolve(); + expect(await pending).toBeUndefined(); + }); + + it('swallows a fetch rejection so a background trigger never throws', async () => { + const ctx = setup(); + + await expect(rejectingTriggers(ctx.logger, 'boom').runBackgroundFetch()).resolves.toBeUndefined(); + + // The full prefix, not just 'boom': getLocalDocs logs its own rejection + // with a different one, and the two must stay distinguishable in a log. + expect(ctx.logger.warns.join('\n')).toContain('[llm-docs] background docs fetch failed: boom'); + }); + + it('returns undefined when the fetch rejects rather than propagating to the caller', async () => { + // getLocalDocs sits on an assistant response path, so a rejected download + // has to read as "no local docs" and fall back to the web, not throw. + const ctx = setup(); + + expect(await rejectingTriggers(ctx.logger, 'boom').getLocalDocs()).toBeUndefined(); + + expect(ctx.logger.warns.join('\n')).toContain('[llm-docs] docs fetch failed: boom'); + expect(ctx.logger.warns.join('\n')).not.toContain('background docs fetch failed'); + }); +}); diff --git a/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts index efde3e2948fd..f2d8a36c32ef 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsValidate.vitest.ts @@ -8,8 +8,15 @@ import { guardEntryNames, validateExtractedBundle } from '../../common/positronD import { FakeFileStore } from './fakes.js'; describe('guardEntryNames', () => { - it('accepts ordinary nested entries', () => { - expect(guardEntryNames(['llms.txt', 'bundle.json', 'release-notes/release-2026-05.llms.md'])).toBeUndefined(); + it.each([ + ['ordinary nested entries', ['llms.txt', 'bundle.json', 'release-notes/release-2026-05.llms.md']], + // The depth counter exists so traversal that stays inside the target is + // allowed. Without a case that must pass, rejecting every '..' outright + // would satisfy the whole rejection table below. + ['a traversal that nets back inside', ['docs/../llms.txt']], + ['a leading ./ and a doubled slash', ['./llms.txt', 'docs//welcome.llms.md']], + ])('accepts %s', (_label, names) => { + expect(guardEntryNames(names)).toBeUndefined(); }); // The archive arrives over the network, so we assert these ourselves rather diff --git a/src/vs/workbench/api/common/extHostExtensionService.ts b/src/vs/workbench/api/common/extHostExtensionService.ts index eb05b9821d6d..a16180f66b4d 100644 --- a/src/vs/workbench/api/common/extHostExtensionService.ts +++ b/src/vs/workbench/api/common/extHostExtensionService.ts @@ -106,6 +106,11 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme private readonly _readyToStartExtensionHost: Barrier; private readonly _readyToRunExtensions: Barrier; private readonly _eagerExtensionsActivated: Barrier; + // --- Start Positron --- + // Opened at the same 10-second-capped point upstream fires onStartupFinished, + // so background work can defer until the activation burst has drained. + private readonly _positronStartupFinished: Barrier; + // --- End Positron --- private readonly _activationEventsReader: SyncedActivationEventsReader; protected readonly _myRegistry: ExtensionDescriptionRegistry; @@ -158,6 +163,9 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme this._readyToStartExtensionHost = new Barrier(); this._readyToRunExtensions = new Barrier(); this._eagerExtensionsActivated = new Barrier(); + // --- Start Positron --- + this._positronStartupFinished = new Barrier(); + // --- End Positron --- this._activationEventsReader = new SyncedActivationEventsReader(this._initData.extensions.activationEvents); this._globalRegistry = new ExtensionDescriptionRegistry(this._activationEventsReader, this._initData.extensions.allExtensions); const myExtensionsSet = new ExtensionIdentifierSet(this._initData.extensions.myExtensions); @@ -698,11 +706,31 @@ export abstract class AbstractExtHostExtensionService extends Disposable impleme Promise.race([eagerExtensionsActivation, timeout(10000)]).then(() => { this._activateAllStartupFinished(); + // --- Start Positron --- + // Positron: see whenStartupFinished(). Opened here rather than off + // _eagerExtensionsActivated because that barrier waits on the uncapped + // activation promise, and a hung eager extension must delay dependent + // background work rather than suppress it. + this._positronStartupFinished.open(); + // --- End Positron --- }); return eagerExtensionsActivation; } + // --- Start Positron --- + /** + * Resolves once eager extension activation has settled, at the same point + * upstream fires `onStartupFinished`. Capped at 10 seconds, so a hung eager + * extension delays this rather than never resolving it. Background work that + * must not compete with startup waits on this instead of guessing a delay + * from construction time. + */ + public async whenStartupFinished(): Promise { + await this._positronStartupFinished.wait(); + } + // --- End Positron --- + private _handleWorkspaceContainsEagerExtensions(folders: ReadonlyArray): Promise { if (folders.length === 0) { return Promise.resolve(undefined); diff --git a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts index 06c8e18b2a7d..eeae17f21e47 100644 --- a/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts +++ b/src/vs/workbench/api/common/positron/extHost.positron.api.impl.ts @@ -46,6 +46,7 @@ import { ExtHostPositronEphemeralStorage } from './extHostPositronEphemeralStora import { IExtHostStorage } from '../extHostStorage.js'; import { ExtHostLifecycle } from './extHostLifecycle.js'; import { ExtHostFileTransfer } from './extHostFileTransfer.js'; +import { IExtHostDocs } from './extHostDocs.js'; /** * Factory interface for creating an instance of the Positron API. @@ -65,6 +66,7 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce const extHostCommands = accessor.get(IExtHostCommands); const extHostLogService = accessor.get(ILogService); const extHostConfiguration = accessor.get(IExtHostConfiguration); + const extHostDocs = accessor.get(IExtHostDocs); // Retrieve the raw `ExtHostWebViews` object from the rpcProtocol; this // object is needed to create webviews, and was previously created in @@ -640,6 +642,16 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce } }; + const docs: typeof positron.docs = { + /** + * Get the locally cached Positron documentation, or undefined when + * the caller should fall back to the web. + */ + async getLocalDocs(): Promise { + return await extHostDocs.getLocalDocs(); + }, + }; + const workspace: typeof positron.workspace = { registerConfigurationMigrations(migrations: ReadonlyArray): vscode.Disposable { extHostConfiguration.registerConfigurationMigrations(extension, migrations); @@ -659,6 +671,7 @@ export function createPositronApiFactoryAndRegisterActors(accessor: ServicesAcce methods, environment, paths, + docs, connections, dataConnections, dataExplorer, diff --git a/src/vs/workbench/api/common/positron/extHostDocs.ts b/src/vs/workbench/api/common/positron/extHostDocs.ts new file mode 100644 index 000000000000..76dd7828a386 --- /dev/null +++ b/src/vs/workbench/api/common/positron/extHostDocs.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as positron from 'positron'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IExtHostDocs = createDecorator('IExtHostDocs'); + +export interface IExtHostDocs { + readonly _serviceBrand: undefined; + + /** + * Resolve the locally cached docs bundle, or undefined when there are none + * and the caller should use the web. + */ + getLocalDocs(): Promise; +} + +/** + * Web-worker extension host variant. + * + * Returns undefined rather than throwing NotSupportedError, because undefined + * is already the documented "no local docs, use the web" contract - throwing + * would force every caller to wrap the call in a try/catch to get the same + * behaviour. There is nothing to download to: the worker host has no + * filesystem, and base/node/zip.ts is node-layer only. + */ +export class WorkerExtHostDocs implements IExtHostDocs { + readonly _serviceBrand: undefined; + + async getLocalDocs(): Promise { + return undefined; + } +} diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 55acd8bd9c11..5ebcc9a33107 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,6 +31,10 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; +// --- Start Positron --- +import { IExtHostDocs } from '../common/positron/extHostDocs.js'; +import { NodeExtHostDocs } from './positron/extHostDocsNode.js'; +// --- End Positron --- // ######################################################################### // ### ### @@ -53,3 +57,10 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); + +// --- Start Positron --- +// Eager so the launch trigger has something to fire from. The constructor only +// arms a scheduler off the startup-finished signal and installs a config +// listener; it never touches the network. +registerSingleton(IExtHostDocs, NodeExtHostDocs, InstantiationType.Eager); +// --- End Positron --- diff --git a/src/vs/workbench/api/node/positron/extHostDocsNode.ts b/src/vs/workbench/api/node/positron/extHostDocsNode.ts new file mode 100644 index 000000000000..3d8a01314339 --- /dev/null +++ b/src/vs/workbench/api/node/positron/extHostDocsNode.ts @@ -0,0 +1,316 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash } from 'crypto'; +import * as fs from 'fs'; +import type * as positron from 'positron'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { dirname as pathDirname } from '../../../../base/common/path.js'; +import { isWorkbench } from '../../../../base/common/platform.js'; +import { dirname, joinPath } from '../../../../base/common/resources.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import * as pfs from '../../../../base/node/pfs.js'; +import { extract } from '../../../../base/node/zip.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import product from '../../../../platform/product/common/product.js'; +import { DocsProfile, IDocsBundleRequest } from '../../../../platform/positronDocs/common/positronDocsBundle.js'; +import { PositronDocsCache } from '../../../../platform/positronDocs/common/positronDocsCache.js'; +import { IDocsArchive, IDocsFileStore, IDocsHttpClient, IDocsHttpGetOptions, IDocsHttpResponse } from '../../../../platform/positronDocs/common/positronDocsIO.js'; +import { PositronDocsTriggers } from '../../../../platform/positronDocs/common/positronDocsTriggers.js'; +import { AI_ENABLED_KEY } from '../../../contrib/positronAssistant/common/positronAIConfigurationKeys.js'; +import { IExtHostConfiguration } from '../../common/extHostConfiguration.js'; +import { IExtHostExtensionService } from '../../common/extHostExtensionService.js'; +import { IExtHostInitDataService } from '../../common/extHostInitDataService.js'; +import { IExtHostDocs } from '../../common/positron/extHostDocs.js'; + +const CACHE_DIR_NAME = 'positron-llm-docs'; +const DEFAULT_BUNDLE_BASE_URL = 'https://cdn.posit.co/positron/releases/docs'; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_REDIRECTS = 3; +// Measured from the startup-finished signal, not from construction: the delay +// exists to stay clear of the eager-activation burst, and construction happens +// before it. See the design spec, "The launch anchor". +const LAUNCH_DELAY_MS = 5_000; +const GET_LOCAL_DOCS_WAIT_MS = 10_000; + +/** + * HTTP over node's https/http. The extension host already proxy-patches these + * modules, so enterprise proxies work with no extra code here. + */ +class NodeDocsHttpClient implements IDocsHttpClient { + + async get(url: string, options: IDocsHttpGetOptions = {}): Promise { + return await this._request(url, 'GET', options, 0); + } + + async head(url: string): Promise { + return await this._request(url, 'HEAD', {}, 0); + } + + private async _request(url: string, method: 'GET' | 'HEAD', options: IDocsHttpGetOptions, redirects: number): Promise { + // Dynamically imported: http and https are slow to load, so the layer + // rules only allow them as type imports at module scope. + const { request: sendRequest } = url.startsWith('http:') ? await import('http') : await import('https'); + return await new Promise((resolve, reject) => { + const headers: Record = {}; + if (options.etag) { + headers['If-None-Match'] = options.etag; + } + const request = sendRequest(url, { method, headers, timeout: REQUEST_TIMEOUT_MS }, response => { + const status = response.statusCode ?? 0; + const location = response.headers.location; + + if (status >= 300 && status < 400 && location) { + response.resume(); + if (redirects >= MAX_REDIRECTS) { + reject(new Error(`too many redirects for ${url}`)); + return; + } + resolve(this._request(new URL(location, url).toString(), method, options, redirects + 1)); + return; + } + + const etag = typeof response.headers.etag === 'string' ? response.headers.etag : undefined; + if (method === 'HEAD' || status === 304 || status !== 200) { + response.resume(); + resolve({ status, etag }); + return; + } + + const chunks: Buffer[] = []; + let total = 0; + response.on('data', (chunk: Buffer) => { + total += chunk.length; + if (options.maxBytes !== undefined && total > options.maxBytes) { + // A wrong or hostile object must not be able to fill the disk. + request.destroy(); + reject(new Error(`response from ${url} exceeds ${options.maxBytes} bytes`)); + return; + } + chunks.push(chunk); + }); + response.on('end', () => resolve({ status, etag, body: new Uint8Array(Buffer.concat(chunks)) })); + response.on('error', reject); + }); + request.on('timeout', () => request.destroy(new Error(`request to ${url} timed out`))); + request.on('error', reject); + request.end(); + }); + } +} + +class NodeDocsFileStore implements IDocsFileStore { + + async exists(target: string): Promise { + return await pfs.Promises.exists(target); + } + + async readFile(target: string): Promise { + return await fs.promises.readFile(target, 'utf8'); + } + + async writeFile(target: string, data: string | Uint8Array): Promise { + await fs.promises.mkdir(pathDirname(target), { recursive: true }); + await pfs.Promises.writeFile(target, data); + } + + async mkdir(target: string): Promise { + await fs.promises.mkdir(target, { recursive: true }); + } + + async rename(from: string, to: string): Promise { + await pfs.Promises.rename(from, to); + } + + async delete(target: string): Promise { + await pfs.Promises.rm(target); + } + + /** Empty array for a missing path, which is what the port promises. */ + async readdir(target: string): Promise { + try { + return await pfs.Promises.readdir(target); + } catch { + return []; + } + } + + async isDirectory(target: string): Promise { + try { + return (await fs.promises.stat(target)).isDirectory(); + } catch { + return false; + } + } + + async sha256(target: string): Promise { + return createHash('sha256').update(await fs.promises.readFile(target)).digest('hex'); + } +} + +class NodeDocsArchive implements IDocsArchive { + + /** + * List entries without extracting, so the traversal guard runs first. + * + * base/node/zip.ts does not export an entry-listing helper, and its own + * check (`targetDirName.startsWith(targetPath)`) is a prefix test that + * ignores the final path segment. The archive arrives over the network, so + * we open it with yauzl ourselves and assert before writing anything. + */ + async entryNames(zipPath: string): Promise { + const { open } = await import('yauzl'); + return await new Promise((resolve, reject) => { + open(zipPath, { lazyEntries: true }, (error, zipfile) => { + if (error || !zipfile) { + reject(error ?? new Error(`could not open ${zipPath}`)); + return; + } + const names: string[] = []; + zipfile.on('entry', entry => { + names.push(entry.fileName); + zipfile.readEntry(); + }); + zipfile.on('end', () => resolve(names)); + zipfile.on('error', reject); + zipfile.readEntry(); + }); + }); + } + + async extract(zipPath: string, targetPath: string): Promise { + await extract(zipPath, targetPath, {}, CancellationToken.None); + } +} + +/** + * Work out what this build should ask the CDN for. + * + * Exported as a free function so it is testable without constructing the + * service or reaching into its internals. + */ +export function deriveBundleRequest(initData: IExtHostInitDataService, env: NodeJS.ProcessEnv): IDocsBundleRequest { + const override = env['POSITRON_LLMS_DOCS_URL']; + return { + quality: initData.quality, + positronVersion: initData.positronVersion, + positronBuildNumber: initData.positronBuildNumber, + // isWorkbench is already `!!process.env.RS_SERVER_URL` on the node side. + profile: (isWorkbench ? 'workbench' : 'positron') as DocsProfile, + baseUrl: (override && override.length > 0) + ? override + : (product.positronLlmsDocsUrl ?? DEFAULT_BUNDLE_BASE_URL), + }; +} + +export class NodeExtHostDocs extends Disposable implements IExtHostDocs { + + readonly _serviceBrand: undefined; + + private readonly _triggers: PositronDocsTriggers; + private readonly _launch: RunOnceScheduler; + + constructor( + @IExtHostInitDataService initData: IExtHostInitDataService, + @IExtHostConfiguration private readonly _configuration: IExtHostConfiguration, + @IExtHostExtensionService private readonly _extensionService: IExtHostExtensionService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // A sibling of globalStorage, so there is no risk of colliding with an + // extension id. Kept namespaced because the rest of that directory is + // upstream-owned. Profile-scoped, which is one 655KB copy per profile. + const root = joinPath(dirname(initData.environment.globalStorageHome), CACHE_DIR_NAME); + const request = deriveBundleRequest(initData, process.env); + const logger = { + info: (message: string) => this._logService.info(message), + warn: (message: string) => this._logService.warn(message), + }; + + const cache = new PositronDocsCache({ + rootPath: root.fsPath, + http: new NodeDocsHttpClient(), + files: new NodeDocsFileStore(), + archive: new NodeDocsArchive(), + logger, + now: () => Date.now(), + newId: () => generateUuid(), + }); + + this._triggers = new PositronDocsTriggers({ + cache, + request, + logger, + isAiEnabled: () => this._isAiEnabled(), + waitMs: GET_LOCAL_DOCS_WAIT_MS, + delay: ms => new Promise(resolve => setTimeout(resolve, ms)), + }); + + // Launch trigger. Nothing above touched the network or the disk: the + // constructor only arms a scheduler and installs a config listener. That + // discipline is what keeps a slow download off the extension + // activation path, and the construction test asserts it. + this._launch = this._register(new RunOnceScheduler(() => { void this._triggers.runBackgroundFetch(); }, LAUNCH_DELAY_MS)); + void this._scheduleLaunchAfterStartup(); + + void this._listenForAiEnabledFlip(); + } + + async getLocalDocs(): Promise { + return await this._triggers.getLocalDocs(); + } + + /** + * The launch delay is measured from the startup-finished signal rather than + * from construction, so the download stays clear of the eager-activation + * burst instead of guessing how long that burst takes. The signal is capped + * at 10 seconds upstream, so a hung eager extension delays this fetch rather + * than suppressing it. Trigger 3 (getLocalDocs) is the backstop either way. + */ + private async _scheduleLaunchAfterStartup(): Promise { + await this._extensionService.whenStartupFinished(); + if (this._store.isDisposed) { + return; + } + this._launch.schedule(); + } + + /** + * ai.enabled toggles without a window reload, so a mid-session flip must + * work. getConfigProvider() is barrier-gated, hence the async helper rather + * than an inline subscription. + */ + private async _listenForAiEnabledFlip(): Promise { + const provider = await this._configuration.getConfigProvider(); + if (this._store.isDisposed) { + return; + } + let enabled = provider.getConfiguration().get(AI_ENABLED_KEY) === true; + this._register(provider.onDidChangeConfiguration(async event => { + if (!event.affectsConfiguration(AI_ENABLED_KEY)) { + return; + } + const next = provider.getConfiguration().get(AI_ENABLED_KEY) === true; + const flippedOn = next && !enabled; + enabled = next; + if (flippedOn) { + await this._triggers.onAiEnabledFlippedTrue(); + } + })); + } + + /** + * Read live rather than caching at construction: ai.enabled is + * WINDOW-scoped and toggles without a reload, so a value captured once in + * the constructor goes stale. + */ + private async _isAiEnabled(): Promise { + const provider = await this._configuration.getConfigProvider(); + return provider.getConfiguration().get(AI_ENABLED_KEY) === true; + } +} diff --git a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts new file mode 100644 index 000000000000..335d3801b744 --- /dev/null +++ b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +/// + +import { randomUUID } from 'crypto'; +import { existsSync } from 'fs'; +import type * as vscode from 'vscode'; +import { tmpdir } from 'os'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { join } from '../../../../../base/common/path.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { PositronDocsTriggers } from '../../../../../platform/positronDocs/common/positronDocsTriggers.js'; +import { stubInterface } from '../../../../../test/vitest/stubInterface.js'; +import { AI_ENABLED_KEY } from '../../../../contrib/positronAssistant/common/positronAIConfigurationKeys.js'; +import { ExtHostConfigProvider, IExtHostConfiguration } from '../../../common/extHostConfiguration.js'; +import { IExtHostExtensionService } from '../../../common/extHostExtensionService.js'; +import { IExtHostInitDataService } from '../../../common/extHostInitDataService.js'; +import { deriveBundleRequest, NodeExtHostDocs } from '../../../node/positron/extHostDocsNode.js'; + +// `quality` is spelled out in the default rather than filled in with `??`, so a +// caller can pass `quality: undefined` to model a dev build. +function initData(overrides: { quality?: string | undefined; version?: string; build?: number } = { quality: 'releases' }) { + return stubInterface({ + quality: overrides.quality, + positronVersion: overrides.version ?? '2026.05.0', + positronBuildNumber: overrides.build ?? 179, + environment: stubInterface({ + globalStorageHome: URI.file('/userdata/User/globalStorage'), + }), + }); +} + +describe('deriveBundleRequest', () => { + it('reads version, build number, and quality from init data', () => { + expect(deriveBundleRequest(initData(), {})).toMatchObject({ + quality: 'releases', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + }); + }); + + it('falls back to the product.json default when no env override is set', () => { + expect(deriveBundleRequest(initData(), {}).baseUrl) + .toBe('https://cdn.posit.co/positron/releases/docs'); + }); + + it('lets POSITRON_LLMS_DOCS_URL take precedence over the product.json value', () => { + // This override is what makes the feature verifiable on demand against + // a local fixture server, since product.json is baked at build time. + expect(deriveBundleRequest(initData(), { POSITRON_LLMS_DOCS_URL: 'http://127.0.0.1:8099/docs' }).baseUrl) + .toBe('http://127.0.0.1:8099/docs'); + }); + + it('ignores an empty POSITRON_LLMS_DOCS_URL rather than building a relative URL', () => { + expect(deriveBundleRequest(initData(), { POSITRON_LLMS_DOCS_URL: '' }).baseUrl) + .toBe('https://cdn.posit.co/positron/releases/docs'); + }); + + it('resolves the profile to positron on desktop', () => { + // isWorkbench is false in the Vitest process, which has no RS_SERVER_URL. + expect(deriveBundleRequest(initData(), {}).profile).toBe('positron'); + }); + + it('passes an undefined quality through for dev builds', () => { + expect(deriveBundleRequest(initData({ quality: undefined }), {}).quality).toBeUndefined(); + }); +}); + +/** Init data for a build whose cache would land under `root`, if it created one. */ +function initDataAt(root: string) { + return stubInterface({ + quality: 'dailies', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + environment: stubInterface({ + globalStorageHome: URI.file(join(root, 'globalStorage')), + }), + }); +} + +/** A cache root under tmpdir that no test creates; unique per call. */ +function cacheRoot(label: string): string { + return join(tmpdir(), `positron-llm-docs-${label}-${randomUUID()}`); +} + +describe('NodeExtHostDocs construction', () => { + // The one risk specific to hosting this on the extension host is a slow or + // hung download landing on an activation path. The constructor must only + // arm a scheduler and start installing a config listener, so with a config + // provider and a startup signal that never resolve it must still return, + // having created no cache directory. + it('performs no filesystem work and does not wait on startup or configuration', async () => { + const root = cacheRoot('ctor'); + const getConfigProvider = vi.fn(() => new Promise(() => { })); + const service = new NodeExtHostDocs( + initDataAt(root), + stubInterface({ getConfigProvider }), + stubInterface({ + // Never resolves: construction must not depend on startup finishing. + whenStartupFinished: () => new Promise(() => { }), + }), + new NullLogService(), + ); + + expect(existsSync(join(root, 'positron-llm-docs'))).toBe(false); + // Called, but never awaited to completion - the listener install is a + // detached continuation, not part of construction. + expect(getConfigProvider).toHaveBeenCalledTimes(1); + service.dispose(); + }); +}); + +describe('NodeExtHostDocs launch anchor', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + function build(startupFinished: Promise) { + // Spying on the trigger rather than the ports: this asserts *when* the + // launch fetch fires, and running the real one would reach the network. + const runBackgroundFetch = vi.spyOn(PositronDocsTriggers.prototype, 'runBackgroundFetch') + .mockResolvedValue(undefined); + const service = new NodeExtHostDocs( + initDataAt(cacheRoot('anchor')), + stubInterface({ + getConfigProvider: async () => stubInterface({ + getConfiguration: () => stubInterface({ get: () => true }), + onDidChangeConfiguration: () => Disposable.None, + }), + }), + stubInterface({ whenStartupFinished: () => startupFinished }), + new NullLogService(), + ); + return { service, runBackgroundFetch }; + } + + it('does not fetch while the startup-finished signal is pending', async () => { + const ctx = build(new Promise(() => { })); + await vi.advanceTimersByTimeAsync(60_000); + expect(ctx.runBackgroundFetch).not.toHaveBeenCalled(); + ctx.service.dispose(); + }); + + it('fetches 5 seconds after the signal resolves', async () => { + let resolve!: () => void; + const ctx = build(new Promise(r => { resolve = r; })); + resolve(); + await vi.advanceTimersByTimeAsync(4_000); + expect(ctx.runBackgroundFetch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2_000); + expect(ctx.runBackgroundFetch).toHaveBeenCalledOnce(); + ctx.service.dispose(); + }); + + it('does not fetch if disposed between the signal and the delay', async () => { + let resolve!: () => void; + const ctx = build(new Promise(r => { resolve = r; })); + resolve(); + ctx.service.dispose(); + await vi.advanceTimersByTimeAsync(60_000); + expect(ctx.runBackgroundFetch).not.toHaveBeenCalled(); + }); +}); + +describe('NodeExtHostDocs ai.enabled flip', () => { + // ai.enabled is WINDOW-scoped and toggles without a reload, so the false-to-true + // flip is the one in-session re-attempt the design allows. Spying on the trigger + // rather than the cache: what matters here is which config transitions reach it. + async function build(initiallyEnabled: boolean) { + const onAiEnabledFlippedTrue = vi.spyOn(PositronDocsTriggers.prototype, 'onAiEnabledFlippedTrue') + .mockResolvedValue(undefined); + const changed = new Emitter(); + const subscribe = vi.fn((listener: (e: vscode.ConfigurationChangeEvent) => unknown) => changed.event(listener)); + let enabled = initiallyEnabled; + + const service = new NodeExtHostDocs( + initDataAt(cacheRoot('flip')), + stubInterface({ + getConfigProvider: async () => stubInterface({ + getConfiguration: () => stubInterface({ get: () => enabled }), + onDidChangeConfiguration: subscribe, + }), + }), + // Never resolves: the launch fetch must stay out of this test's way. + stubInterface({ whenStartupFinished: () => new Promise(() => { }) }), + new NullLogService(), + ); + + // The listener install is a detached continuation off an awaited + // getConfigProvider(), so it is not in place when the constructor returns. + // Waiting on the subscription itself beats guessing at a microtask count. + await vi.waitFor(() => expect(subscribe).toHaveBeenCalledTimes(1)); + + return { + service, onAiEnabledFlippedTrue, + /** Move ai.enabled and fire the change event the provider would. */ + set: async (next: boolean, affectedKey = AI_ENABLED_KEY) => { + enabled = next; + changed.fire({ affectsConfiguration: (section: string) => section === affectedKey }); + await Promise.resolve(); + }, + }; + } + + it('refetches when ai.enabled flips false to true', async () => { + const ctx = await build(false); + + await ctx.set(true); + + expect(ctx.onAiEnabledFlippedTrue).toHaveBeenCalledTimes(1); + ctx.service.dispose(); + }); + + it('does not refetch when ai.enabled flips true to false', async () => { + const ctx = await build(true); + + await ctx.set(false); + + expect(ctx.onAiEnabledFlippedTrue).not.toHaveBeenCalled(); + ctx.service.dispose(); + }); + + it('does not refetch when a change leaves ai.enabled true', async () => { + // A rewrite of the same value still fires the event. Treating it as a flip + // would re-download the bundle on every unrelated settings.json save. + const ctx = await build(true); + + await ctx.set(true); + + expect(ctx.onAiEnabledFlippedTrue).not.toHaveBeenCalled(); + ctx.service.dispose(); + }); + + it('ignores a change to an unrelated setting', async () => { + const ctx = await build(false); + + // The value moved, but this event does not claim to affect ai.enabled. + await ctx.set(true, 'editor.fontSize'); + + expect(ctx.onAiEnabledFlippedTrue).not.toHaveBeenCalled(); + ctx.service.dispose(); + }); + + it('fires once per flip, not once per event', async () => { + const ctx = await build(false); + + await ctx.set(true); + await ctx.set(true); + await ctx.set(false); + await ctx.set(true); + + expect(ctx.onAiEnabledFlippedTrue).toHaveBeenCalledTimes(2); + ctx.service.dispose(); + }); + + it('stops listening once disposed', async () => { + const ctx = await build(false); + ctx.service.dispose(); + + await ctx.set(true); + + expect(ctx.onAiEnabledFlippedTrue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index d6055bcf0f6d..5be91b9d8ef0 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -12,6 +12,9 @@ import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { ExtHostExtensionService } from './extHostExtensionService.js'; +// --- Start Positron --- +import { IExtHostDocs, WorkerExtHostDocs } from '../common/positron/extHostDocs.js'; +// --- End Positron --- // ######################################################################### // ### ### @@ -24,3 +27,10 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true)); + +// --- Start Positron --- +// The Positron API factory runs in this host too, so positron.docs needs a +// registration here. The worker host has no filesystem, so it gets the +// always-undefined variant. +registerSingleton(IExtHostDocs, WorkerExtHostDocs, InstantiationType.Eager); +// --- End Positron --- diff --git a/src/vs/workbench/contrib/positronAssistant/common/positronAIConfiguration.ts b/src/vs/workbench/contrib/positronAssistant/common/positronAIConfiguration.ts index 7eeaa83fee22..be89b07c152c 100644 --- a/src/vs/workbench/contrib/positronAssistant/common/positronAIConfiguration.ts +++ b/src/vs/workbench/contrib/positronAssistant/common/positronAIConfiguration.ts @@ -10,19 +10,12 @@ import { IConfigurationRegistry, } from '../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; +import { AI_ENABLED_KEY } from './positronAIConfigurationKeys.js'; -/** - * Main switch for Positron's AI features. When off, all of Positron's AI - * features (Next Edit Suggestions, notebook AI, console Fix/Explain, etc.) are - * turned off. - * - * Owned by Positron. It sits above the Posit Assistant extension's - * `assistant.enabled` (which controls the chat UI): Posit Assistant also reads - * `ai.enabled`, so when it's off the assistant is off regardless of - * `assistant.enabled`. This setting seeds the `ai.*` namespace for - * Positron-owned AI configuration. - */ -export const AI_ENABLED_KEY = 'ai.enabled'; +// Re-exported so existing importers do not have to move. New callers outside +// the workbench (e.g. the extension host) should import the keys module +// directly to avoid this file's registerConfiguration side effect. +export { AI_ENABLED_KEY }; const configurationRegistry = Registry.as(Extensions.Configuration); configurationRegistry.registerConfiguration({ diff --git a/src/vs/workbench/contrib/positronAssistant/common/positronAIConfigurationKeys.ts b/src/vs/workbench/contrib/positronAssistant/common/positronAIConfigurationKeys.ts new file mode 100644 index 000000000000..5b830829790a --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/common/positronAIConfigurationKeys.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Main switch for Positron's AI features. When off, all of Positron's AI + * features (Next Edit Suggestions, notebook AI, console Fix/Explain, etc.) are + * turned off. + * + * Owned by Positron. It sits above the Posit Assistant extension's + * `assistant.enabled` (which controls the chat UI): Posit Assistant also reads + * `ai.enabled`, so when it's off the assistant is off regardless of + * `assistant.enabled`. This setting seeds the `ai.*` namespace for + * Positron-owned AI configuration. + * + * This module is deliberately free of imports and side effects so processes + * without a Settings UI - notably the extension host - can read the key without + * pulling in the configuration registry or re-registering the `ai` node. The + * registration itself lives in `positronAIConfiguration.ts`. + */ +export const AI_ENABLED_KEY = 'ai.enabled';