From 6faa11c1bbd8722c67078883c559a53d6b52643a Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:19:38 -0500 Subject: [PATCH 01/15] Extract AI_ENABLED_KEY into a side-effect-free module --- .../common/positronAIConfiguration.ts | 17 +++++--------- .../common/positronAIConfigurationKeys.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 src/vs/workbench/contrib/positronAssistant/common/positronAIConfigurationKeys.ts 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'; From bf4d3d82ed772f70adb6dbfdec1ee52236e5c430 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:20:36 -0500 Subject: [PATCH 02/15] Declare positron.docs API surface and the LLM docs bundle URL --- product.json | 1 + src/positron-dts/positron.d.ts | 47 ++++++++++++++++++++++++++++++++++ src/vs/base/common/product.ts | 7 +++++ 3 files changed, 55 insertions(+) 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 --- From 0d77a75bb536a7d2b1e4d372f94cacfc4ec3cf4c Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:21:42 -0500 Subject: [PATCH 03/15] Add IExtHostDocs service and its web-worker variant --- .../api/common/positron/extHostDocs.ts | 36 +++++++++++++++++++ .../api/worker/extHost.worker.services.ts | 10 ++++++ 2 files changed, 46 insertions(+) create mode 100644 src/vs/workbench/api/common/positron/extHostDocs.ts 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/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 --- From ce5ba7bbb9013c954eb5495a19a6557ec82c397c Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:25:20 -0500 Subject: [PATCH 04/15] Add node docs port adapters and the extension-host docs service --- .../api/node/extHost.node.services.ts | 11 + .../api/node/positron/extHostDocsNode.ts | 251 ++++++++++++++++++ .../node/positron/extHostDocsNode.vitest.ts | 92 +++++++ 3 files changed, 354 insertions(+) create mode 100644 src/vs/workbench/api/node/positron/extHostDocsNode.ts create mode 100644 src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts 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..fd430f3bfa40 --- /dev/null +++ b/src/vs/workbench/api/node/positron/extHostDocsNode.ts @@ -0,0 +1,251 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { AI_ENABLED_KEY } from '../../../contrib/positronAssistant/common/positronAIConfigurationKeys.js'; +import { IExtHostConfiguration } from '../../common/extHostConfiguration.js'; +import { IExtHostInitDataService } from '../../common/extHostInitDataService.js'; +import { IExtHostDocs } from '../../common/positron/extHostDocs.js'; + +const CACHE_DIR_NAME = 'positron-docs'; +const DEFAULT_BUNDLE_BASE_URL = 'https://cdn.posit.co/positron/releases/docs'; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_REDIRECTS = 3; + +/** + * 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 _cache: PositronDocsCache; + private readonly _request: IDocsBundleRequest; + + constructor( + @IExtHostInitDataService initData: IExtHostInitDataService, + @IExtHostConfiguration private readonly _configuration: IExtHostConfiguration, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // A sibling of globalStorage, so there is no risk of colliding with an + // extension id. Profile-scoped, which is one 655KB copy per profile. + const root = joinPath(dirname(initData.environment.globalStorageHome), CACHE_DIR_NAME); + + this._request = deriveBundleRequest(initData, process.env); + this._cache = new PositronDocsCache({ + rootPath: root.fsPath, + http: new NodeDocsHttpClient(), + files: new NodeDocsFileStore(), + archive: new NodeDocsArchive(), + logger: { + info: message => this._logService.info(message), + warn: message => this._logService.warn(message), + }, + now: () => Date.now(), + newId: () => generateUuid(), + }); + } + + async getLocalDocs(): Promise { + if (!await this._isAiEnabled()) { + return undefined; + } + return await this._cache.ensure(this._request); + } + + /** + * 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..dd9e38058e50 --- /dev/null +++ b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { stubInterface } from '../../../../../test/vitest/stubInterface.js'; +import { IExtHostConfiguration } from '../../../common/extHostConfiguration.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(); + }); +}); + +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 + // install a scheduler and a config listener, so it must not have created + // the cache directory or resolved the barrier-gated config provider. + it('performs no filesystem or configuration work', async () => { + const root = join(tmpdir(), `positron-docs-ctor-${randomUUID()}`); + const getConfigProvider = vi.fn(() => new Promise(() => { })); + const service = new NodeExtHostDocs( + stubInterface({ + quality: 'dailies', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + environment: stubInterface({ + globalStorageHome: URI.file(join(root, 'globalStorage')), + }), + }), + stubInterface({ getConfigProvider }), + new NullLogService(), + ); + + expect(existsSync(join(root, 'positron-docs'))).toBe(false); + expect(getConfigProvider).not.toHaveBeenCalled(); + service.dispose(); + }); +}); From 56ad3e8dfcdc741764331f5ab62fedbe8b640cb1 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:26:46 -0500 Subject: [PATCH 05/15] Publish the extension-host startup-finished moment as an awaitable signal --- .../api/common/extHostExtensionService.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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); From 7698d740a9d72536ff820ec50f4be85f09c187d0 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:32:21 -0500 Subject: [PATCH 06/15] Add docs fetch triggers with ai.enabled gating and a bounded wait --- .../positronDocs/common/positronDocsCache.ts | 15 ++ .../common/positronDocsTriggers.ts | 96 +++++++++++++ .../common/positronDocsTriggers.vitest.ts | 129 ++++++++++++++++++ .../api/node/positron/extHostDocsNode.ts | 86 ++++++++++-- .../node/positron/extHostDocsNode.vitest.ts | 80 ++++++++++- 5 files changed, 390 insertions(+), 16 deletions(-) create mode 100644 src/vs/platform/positronDocs/common/positronDocsTriggers.ts create mode 100644 src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts 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..f0a71dd88589 --- /dev/null +++ b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * 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'; + +const LOG_PREFIX = '[positron-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()) { + 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/positronDocsTriggers.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts new file mode 100644 index 000000000000..4d06babe84c3 --- /dev/null +++ b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * 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 }; +} + +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(); + }); + + 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(); + const ensure = vi.fn(async () => { throw new Error('boom'); }); + const triggers = new PositronDocsTriggers({ + cache: { ensure, peek: async () => undefined, invalidate: vi.fn() }, + request: REQUEST, logger: ctx.logger, + isAiEnabled: async () => true, waitMs: 10_000, delay: () => new Promise(() => { }), + }); + + await expect(triggers.runBackgroundFetch()).resolves.toBeUndefined(); + expect(ctx.logger.warns.join('\n')).toContain('boom'); + }); +}); diff --git a/src/vs/workbench/api/node/positron/extHostDocsNode.ts b/src/vs/workbench/api/node/positron/extHostDocsNode.ts index fd430f3bfa40..73c7514b61f9 100644 --- a/src/vs/workbench/api/node/positron/extHostDocsNode.ts +++ b/src/vs/workbench/api/node/positron/extHostDocsNode.ts @@ -6,6 +6,7 @@ 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'; @@ -19,8 +20,10 @@ 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'; @@ -28,6 +31,11 @@ const CACHE_DIR_NAME = 'positron-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 @@ -203,12 +211,13 @@ export class NodeExtHostDocs extends Disposable implements IExtHostDocs { readonly _serviceBrand: undefined; - private readonly _cache: PositronDocsCache; - private readonly _request: IDocsBundleRequest; + 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(); @@ -216,27 +225,82 @@ export class NodeExtHostDocs extends Disposable implements IExtHostDocs { // A sibling of globalStorage, so there is no risk of colliding with an // extension id. 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), + }; - this._request = deriveBundleRequest(initData, process.env); - this._cache = new PositronDocsCache({ + const cache = new PositronDocsCache({ rootPath: root.fsPath, http: new NodeDocsHttpClient(), files: new NodeDocsFileStore(), archive: new NodeDocsArchive(), - logger: { - info: message => this._logService.info(message), - warn: message => this._logService.warn(message), - }, + 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 { - if (!await this._isAiEnabled()) { - return undefined; + 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; } - return await this._cache.ensure(this._request); + 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(); + } + })); } /** diff --git a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts index dd9e38058e50..60e9edcb2bef 100644 --- a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts +++ b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts @@ -6,12 +6,16 @@ import { randomUUID } from 'crypto'; import { existsSync } from 'fs'; +import type * as vscode from 'vscode'; import { tmpdir } from 'os'; +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 { IExtHostConfiguration } from '../../../common/extHostConfiguration.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'; @@ -67,9 +71,10 @@ describe('deriveBundleRequest', () => { 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 - // install a scheduler and a config listener, so it must not have created - // the cache directory or resolved the barrier-gated config provider. - it('performs no filesystem or configuration work', async () => { + // 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 = join(tmpdir(), `positron-docs-ctor-${randomUUID()}`); const getConfigProvider = vi.fn(() => new Promise(() => { })); const service = new NodeExtHostDocs( @@ -82,11 +87,76 @@ describe('NodeExtHostDocs construction', () => { }), }), stubInterface({ getConfigProvider }), + stubInterface({ + // Never resolves: construction must not depend on startup finishing. + whenStartupFinished: () => new Promise(() => { }), + }), new NullLogService(), ); expect(existsSync(join(root, 'positron-docs'))).toBe(false); - expect(getConfigProvider).not.toHaveBeenCalled(); + // 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 root = join(tmpdir(), `positron-docs-anchor-${randomUUID()}`); + const service = new NodeExtHostDocs( + stubInterface({ + quality: 'dailies', + positronVersion: '2026.05.0', + positronBuildNumber: 179, + environment: stubInterface({ + globalStorageHome: URI.file(join(root, 'globalStorage')), + }), + }), + 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(); + }); +}); From a2206aa800d045763ce250fe689859c730209087 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 06:33:31 -0500 Subject: [PATCH 07/15] Expose the positron.docs namespace to extensions --- .../common/positron/extHost.positron.api.impl.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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, From cb4a6349cb14494eff1142a2d5cfc59a05d5c09d Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 07:31:14 -0500 Subject: [PATCH 08/15] Log when local docs are withheld because ai.enabled is off --- src/vs/platform/positronDocs/common/positronDocsTriggers.ts | 4 ++++ .../positronDocs/test/common/positronDocsTriggers.vitest.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts index f0a71dd88589..dcf3a3ef00fe 100644 --- a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts +++ b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts @@ -71,6 +71,10 @@ export class PositronDocsTriggers { 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; } diff --git a/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts index 4d06babe84c3..97310a7312e6 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts @@ -54,6 +54,9 @@ describe('PositronDocsTriggers: ai.enabled gating', () => { 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 () => { From 22bb82af11f69ca4696c17402945638abcad6d2c Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 07:36:51 -0500 Subject: [PATCH 09/15] Match the llm-docs log prefix in the trigger layer --- src/vs/platform/positronDocs/common/positronDocsTriggers.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts index dcf3a3ef00fe..d1fd5ab2132b 100644 --- a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts +++ b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts @@ -6,7 +6,8 @@ import { IDocsBundleRequest } from './positronDocsBundle.js'; import { IDocsLogger, ILocalDocs } from './positronDocsIO.js'; -const LOG_PREFIX = '[positron-docs]'; +// 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 { From 69f8f17c60964c52b0687b7c11780900162f81b5 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 07:45:54 -0500 Subject: [PATCH 10/15] Name the docs cache directory positron-llm-docs in the extension host --- src/vs/workbench/api/node/positron/extHostDocsNode.ts | 5 +++-- .../api/test/node/positron/extHostDocsNode.vitest.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/api/node/positron/extHostDocsNode.ts b/src/vs/workbench/api/node/positron/extHostDocsNode.ts index 73c7514b61f9..3d8a01314339 100644 --- a/src/vs/workbench/api/node/positron/extHostDocsNode.ts +++ b/src/vs/workbench/api/node/positron/extHostDocsNode.ts @@ -27,7 +27,7 @@ import { IExtHostExtensionService } from '../../common/extHostExtensionService.j import { IExtHostInitDataService } from '../../common/extHostInitDataService.js'; import { IExtHostDocs } from '../../common/positron/extHostDocs.js'; -const CACHE_DIR_NAME = 'positron-docs'; +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; @@ -223,7 +223,8 @@ export class NodeExtHostDocs extends Disposable implements IExtHostDocs { super(); // A sibling of globalStorage, so there is no risk of colliding with an - // extension id. Profile-scoped, which is one 655KB copy per profile. + // 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 = { diff --git a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts index 60e9edcb2bef..39a24647575d 100644 --- a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts +++ b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts @@ -75,7 +75,7 @@ describe('NodeExtHostDocs construction', () => { // 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 = join(tmpdir(), `positron-docs-ctor-${randomUUID()}`); + const root = join(tmpdir(), `positron-llm-docs-ctor-${randomUUID()}`); const getConfigProvider = vi.fn(() => new Promise(() => { })); const service = new NodeExtHostDocs( stubInterface({ @@ -94,7 +94,7 @@ describe('NodeExtHostDocs construction', () => { new NullLogService(), ); - expect(existsSync(join(root, 'positron-docs'))).toBe(false); + 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); @@ -111,7 +111,7 @@ describe('NodeExtHostDocs launch anchor', () => { // launch fetch fires, and running the real one would reach the network. const runBackgroundFetch = vi.spyOn(PositronDocsTriggers.prototype, 'runBackgroundFetch') .mockResolvedValue(undefined); - const root = join(tmpdir(), `positron-docs-anchor-${randomUUID()}`); + const root = join(tmpdir(), `positron-llm-docs-anchor-${randomUUID()}`); const service = new NodeExtHostDocs( stubInterface({ quality: 'dailies', From 6194dbc4e6957366b54f6a4251a998c401543a6b Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Fri, 31 Jul 2026 08:00:39 -0500 Subject: [PATCH 11/15] more test coverage --- .../test/common/positronDocsCache.vitest.ts | 48 ++++++ .../common/positronDocsTriggers.vitest.ts | 44 ++++-- .../common/positronDocsValidate.vitest.ts | 11 +- .../node/positron/extHostDocsNode.vitest.ts | 141 +++++++++++++++--- 4 files changed, 215 insertions(+), 29 deletions(-) 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 index 97310a7312e6..20ab48106e93 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsTriggers.vitest.ts @@ -42,6 +42,24 @@ function setup(options: { aiEnabled?: boolean; peeked?: ILocalDocs } = {}) { 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 }); @@ -119,14 +137,22 @@ describe('PositronDocsTriggers: joining and the bounded wait', () => { it('swallows a fetch rejection so a background trigger never throws', async () => { const ctx = setup(); - const ensure = vi.fn(async () => { throw new Error('boom'); }); - const triggers = new PositronDocsTriggers({ - cache: { ensure, peek: async () => undefined, invalidate: vi.fn() }, - request: REQUEST, logger: ctx.logger, - isAiEnabled: async () => true, waitMs: 10_000, delay: () => new Promise(() => { }), - }); - - await expect(triggers.runBackgroundFetch()).resolves.toBeUndefined(); - expect(ctx.logger.warns.join('\n')).toContain('boom'); + + 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/test/node/positron/extHostDocsNode.vitest.ts b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts index 39a24647575d..335d3801b744 100644 --- a/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts +++ b/src/vs/workbench/api/test/node/positron/extHostDocsNode.vitest.ts @@ -8,12 +8,14 @@ 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'; @@ -68,6 +70,23 @@ describe('deriveBundleRequest', () => { }); }); +/** 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 @@ -75,17 +94,10 @@ describe('NodeExtHostDocs construction', () => { // 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 = join(tmpdir(), `positron-llm-docs-ctor-${randomUUID()}`); + const root = cacheRoot('ctor'); const getConfigProvider = vi.fn(() => new Promise(() => { })); const service = new NodeExtHostDocs( - stubInterface({ - quality: 'dailies', - positronVersion: '2026.05.0', - positronBuildNumber: 179, - environment: stubInterface({ - globalStorageHome: URI.file(join(root, 'globalStorage')), - }), - }), + initDataAt(root), stubInterface({ getConfigProvider }), stubInterface({ // Never resolves: construction must not depend on startup finishing. @@ -111,16 +123,8 @@ describe('NodeExtHostDocs launch anchor', () => { // launch fetch fires, and running the real one would reach the network. const runBackgroundFetch = vi.spyOn(PositronDocsTriggers.prototype, 'runBackgroundFetch') .mockResolvedValue(undefined); - const root = join(tmpdir(), `positron-llm-docs-anchor-${randomUUID()}`); const service = new NodeExtHostDocs( - stubInterface({ - quality: 'dailies', - positronVersion: '2026.05.0', - positronBuildNumber: 179, - environment: stubInterface({ - globalStorageHome: URI.file(join(root, 'globalStorage')), - }), - }), + initDataAt(cacheRoot('anchor')), stubInterface({ getConfigProvider: async () => stubInterface({ getConfiguration: () => stubInterface({ get: () => true }), @@ -160,3 +164,104 @@ describe('NodeExtHostDocs launch anchor', () => { 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(); + }); +}); From c05e52dc1e076b323b38e3db73a4cb46b1b6acf0 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Mon, 3 Aug 2026 06:30:29 -0500 Subject: [PATCH 12/15] Use async/await for the getLocalDocs race branches --- .../common/positronDocsTriggers.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts index d1fd5ab2132b..33b8ca84b6a5 100644 --- a/src/vs/platform/positronDocs/common/positronDocsTriggers.ts +++ b/src/vs/platform/positronDocs/common/positronDocsTriggers.ts @@ -70,7 +70,7 @@ export class PositronDocsTriggers { * or returns the completed result. */ async getLocalDocs(): Promise { - const { cache, delay, logger, request, waitMs } = this._options; + const { cache, 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 @@ -79,15 +79,7 @@ export class PositronDocsTriggers { 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]); + const winner = await Promise.race([this._fetchDocs(), this._timeOut()]); if (!winner.timedOut) { return winner.docs; } @@ -98,4 +90,20 @@ export class PositronDocsTriggers { logger.info(`${LOG_PREFIX} local docs not ready within ${waitMs}ms; continuing in the background`); return await cache.peek(request); } + + /** Fetch branch of the race. Absorbs its own failure so the race never rejects. */ + private async _fetchDocs(): Promise { + try { + return { timedOut: false, docs: await this._options.cache.ensure(this._options.request) }; + } catch (error) { + this._options.logger.warn(`${LOG_PREFIX} docs fetch failed: ${error instanceof Error ? error.message : String(error)}`); + return { timedOut: false, docs: undefined }; + } + } + + /** Timeout branch of the race. */ + private async _timeOut(): Promise { + await this._options.delay(this._options.waitMs); + return { timedOut: true }; + } } From e0bea221264e2b203d59cd9a1247a2486ec56722 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Mon, 3 Aug 2026 06:30:32 -0500 Subject: [PATCH 13/15] Cover a concurrent window installing a bundle mid-fetch --- .../positronDocs/test/common/fakes.ts | 16 +++ .../test/common/positronDocsCache.vitest.ts | 98 ++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/positronDocs/test/common/fakes.ts b/src/vs/platform/positronDocs/test/common/fakes.ts index 8896ad4b25e1..969a30367adb 100644 --- a/src/vs/platform/positronDocs/test/common/fakes.ts +++ b/src/vs/platform/positronDocs/test/common/fakes.ts @@ -143,6 +143,13 @@ export interface IFakeHttpRoute { readonly throws?: string; /** Response size in bytes for the maxBytes check; defaults to body length. */ readonly byteLength?: number; + /** + * Awaited before the response is produced, on both GET and HEAD, so a test + * can suspend one caller inside its request and run another window's work in + * the gap. Deterministic by construction: the test decides when the request + * resumes, rather than hoping a timer lands in the right interleaving. + */ + readonly onRequest?: () => Promise; } export class FakeHttpClient implements IDocsHttpClient { @@ -158,6 +165,9 @@ export class FakeHttpClient implements IDocsHttpClient { async get(url: string, options?: IDocsHttpGetOptions): Promise { this.getCalls.push(url); const route = this.routes.get(url) ?? { status: 404 }; + if (route.onRequest) { + await route.onRequest(); + } if (route.throws) { throw new Error(route.throws); } @@ -177,6 +187,12 @@ export class FakeHttpClient implements IDocsHttpClient { async head(url: string): Promise { this.headCalls.push(url); const route = this.routes.get(url) ?? { status: 404 }; + // Honoured here as well as in get(): a release build's convergence probe + // is a HEAD, so a test pausing that probe would otherwise be ignored + // silently rather than failing. + if (route.onRequest) { + await route.onRequest(); + } if (route.throws) { throw new Error(route.throws); } diff --git a/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts index ce2733336d0e..2d93d0f08982 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsCache.vitest.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ /// +import { DeferredPromise } from '../../../../base/common/async.js'; import { DOCS_MAX_DOWNLOAD_BYTES, IDocsBundleRequest } from '../../common/positronDocsBundle.js'; import { PositronDocsCache } from '../../common/positronDocsCache.js'; import { fakeDigest, fakeZip, FakeArchive, FakeFileStore, FakeHttpClient, recordingLogger } from './fakes.js'; @@ -55,15 +56,65 @@ function setup() { newId: () => `id${++ids}`, }); const cache = makeCache(); + /** Route `zipUrl` on `client`, with a matching checksum file. */ + const publishOn = (client: FakeHttpClient, zipUrl: string, body: string, etag?: string) => { + client.route(zipUrl, { status: 200, body, etag }); + client.route(`${zipUrl}.sha256sum`, { status: 200, body: `${fakeDigest(body)} bundle.zip\n` }); + }; return { cache, makeCache, files, http, archive, logger, advance: (ms: number) => { clock += ms; }, /** The persisted cache state, parsed. */ readState: async () => JSON.parse(await files.readFile(STATE_PATH)), /** Serve `zipUrl` with a matching, correctly-formatted checksum file. */ - publish: (zipUrl: string, body: string, etag?: string) => { - http.route(zipUrl, { status: 200, body, etag }); - http.route(`${zipUrl}.sha256sum`, { status: 200, body: `${fakeDigest(body)} bundle.zip\n` }); + publish: (zipUrl: string, body: string, etag?: string) => publishOn(http, zipUrl, body, etag), + /** + * A separate extension host over the same cache directory. + * + * Unlike `makeCache()` - which models the *next launch* of this window, + * sharing its HTTP client - this models a *concurrent* window: its own + * HTTP stack, so one window can see the alias fail while the other sees + * it succeed, over the same files. + */ + openWindow: (idPrefix: string) => { + const windowHttp = new FakeHttpClient(); + const windowLogger = recordingLogger(); + let windowIds = 0; + return { + http: windowHttp, + logger: windowLogger, + cache: new PositronDocsCache({ + rootPath: ROOT, files, archive, + http: windowHttp, + logger: windowLogger, + now: () => clock, + newId: () => `${idPrefix}${++windowIds}`, + }), + publish: (zipUrl: string, body: string, etag?: string) => publishOn(windowHttp, zipUrl, body, etag), + }; + }, + }; +} + +/** A dailies build, which targets the `latest` alias with no HEAD probe. */ +const DAILIES = request({ quality: 'dailies' }); + +/** + * A pause the test can drop inside a fake HTTP request. + * + * Pass `hold` as a route's `onRequest`. Then `await reached` blocks until the + * request arrives, and `release()` lets it finish. The interleaving is chosen + * by the test rather than by timing, so there is nothing here to flake. + */ +function pausable() { + const arrived = new DeferredPromise(); + const released = new DeferredPromise(); + return { + reached: arrived.p, + release: () => released.complete(undefined), + hold: async () => { + arrived.complete(undefined); + await released.p; }, }; } @@ -667,6 +718,47 @@ describe('PositronDocsCache: superseded version cleanup', () => { expect(await ctx.files.exists(`${ROOT}/2026.04.0-100`)).toBe(false); }); + /** + * Two Positron windows share one cache directory. + * + * Window A starts a download that will fail slowly. While it is still + * waiting, window B finishes installing a good bundle. When A finally fails + * it must record that failure without erasing the version B just wrote: + * state.json is the only thing that names a bundle directory, so overwriting + * it strands B's bundle on disk where no later session will ever find it. + */ + it('does not orphan a bundle another window installed mid-fetch', async () => { + const ctx = setup(); + const windowA = ctx.openWindow('a'); + const windowB = ctx.openWindow('b'); + + // Window A's download stalls where the test can hold it, then 503s. + const download = pausable(); + windowA.http.route(LATEST_ZIP, { status: 503, onRequest: download.hold }); + windowB.publish(LATEST_ZIP, payload('2026.05.0-179')); + + // Deliberately not awaited: A has to stay in flight while B runs. Await + // it here and there is no gap for B to install into. + const windowAFinished = windowA.cache.ensure(DAILIES); + + await download.reached; + await windowB.cache.ensure(DAILIES); + expect((await ctx.readState()).version).toBe('2026.05.0-179'); + + download.release(); + const docsA = await windowAFinished; + + const state = await ctx.readState(); + // B's version survives A's failure write... + expect(state.version).toBe('2026.05.0-179'); + // ...and A's failure is still recorded, so the throttle works. + expect(state.lastFailureAt).toBeDefined(); + // Cache-present rule across windows: A serves what B installed. + expect(docsA?.version).toBe('2026.05.0-179'); + // A later session finds it too, rather than downloading it again. + expect((await ctx.makeCache().peek(DAILIES))?.version).toBe('2026.05.0-179'); + }); + it('never touches entries it did not supersede, including another window in-flight work', async () => { const ctx = setup(); ctx.publish(EXACT_ZIP, payload('2026.05.0-179')); From f3a34f4dd113a85f6b15d4d21774f489d6bfa03c Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Mon, 3 Aug 2026 06:30:37 -0500 Subject: [PATCH 14/15] Cap the checksum download size --- src/vs/platform/positronDocs/common/positronDocsBundle.ts | 7 +++++++ src/vs/platform/positronDocs/common/positronDocsCache.ts | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/platform/positronDocs/common/positronDocsBundle.ts b/src/vs/platform/positronDocs/common/positronDocsBundle.ts index 4df4906b035f..25b0b08f7b47 100644 --- a/src/vs/platform/positronDocs/common/positronDocsBundle.ts +++ b/src/vs/platform/positronDocs/common/positronDocsBundle.ts @@ -32,6 +32,13 @@ export const DOCS_INDEX_FILENAME = 'llms.txt'; */ export const DOCS_MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; +/** + * Cap on the checksum file, which holds one `<64 hex chars> ` line. + * Tight rather than generous: unlike the bundle this has a fixed shape, so a + * body anywhere near this size is already a wrong or hostile object. + */ +export const DOCS_MAX_CHECKSUM_BYTES = 8 * 1024; + export type DocsProfile = 'positron' | 'workbench'; /** diff --git a/src/vs/platform/positronDocs/common/positronDocsCache.ts b/src/vs/platform/positronDocs/common/positronDocsCache.ts index 7ea2daba2409..f38fa6d1c99f 100644 --- a/src/vs/platform/positronDocs/common/positronDocsCache.ts +++ b/src/vs/platform/positronDocs/common/positronDocsCache.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { - DOCS_BUNDLE_SCHEMA, DOCS_FAILURE_THROTTLE_MS, DOCS_MAX_DOWNLOAD_BYTES, + DOCS_BUNDLE_SCHEMA, DOCS_FAILURE_THROTTLE_MS, DOCS_MAX_CHECKSUM_BYTES, DOCS_MAX_DOWNLOAD_BYTES, DOCS_STATE_FILENAME, DocsResolution, IDocsBundleManifest, IDocsBundleRequest, IDocsCacheState, IResolvedBundle, IResolvedBundleRequest, parseDigestFile, resolveBundleRequest, @@ -328,7 +328,7 @@ export class PositronDocsCache { // A zip that cannot be verified is never extracted, even though // that means a cold cache gets no local docs until the checksum file // appears. Proceeding unverified would make the digest decorative. - const checksum = await http.get(target.sha256Url); + const checksum = await http.get(target.sha256Url, { maxBytes: DOCS_MAX_CHECKSUM_BYTES }); if (checksum.status !== 200 || !checksum.body) { return { kind: 'rejected', reason: `checksum file unavailable (HTTP ${checksum.status})` }; } From dad932dc6da2813b8b47706b3463f6dd8bd1c314 Mon Sep 17 00:00:00 2001 From: Marie Idleman Date: Mon, 3 Aug 2026 06:33:25 -0500 Subject: [PATCH 15/15] Reject manifest versions that are not safe directory names --- .../positronDocs/common/positronDocsBundle.ts | 18 ++++++++++++++++++ .../test/common/positronDocsBundle.vitest.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/src/vs/platform/positronDocs/common/positronDocsBundle.ts b/src/vs/platform/positronDocs/common/positronDocsBundle.ts index 25b0b08f7b47..2bc89e089a1c 100644 --- a/src/vs/platform/positronDocs/common/positronDocsBundle.ts +++ b/src/vs/platform/positronDocs/common/positronDocsBundle.ts @@ -146,6 +146,21 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } +/** Versions we are willing to use as a directory name. Excludes `.` and `..`. */ +const SAFE_VERSION = /^[A-Za-z0-9._-]+$/; + +/** + * The manifest version becomes a path segment under the cache root, so it is + * validated here rather than at the point of use. + * + * Same reasoning as guardEntryNames: the checksum proves the bundle arrived + * intact, not that the CDN that served it is honest, so a value like `../..` + * would otherwise let a rename escape the cache root. + */ +function isSafePathSegment(value: string): boolean { + return SAFE_VERSION.test(value) && value !== '.' && value !== '..'; +} + /** * Parse bundle.json, rejecting anything this build cannot read. * @@ -175,6 +190,9 @@ export function parseManifest(raw: string): IDocsBundleManifest | undefined { if (typeof candidate.fileCount !== 'number' || !Number.isInteger(candidate.fileCount) || candidate.fileCount <= 0) { return undefined; } + if (!isSafePathSegment(candidate.version)) { + return undefined; + } return { schema: candidate.schema, profile: candidate.profile, diff --git a/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts b/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts index 77576b890c8a..22be48b79b6c 100644 --- a/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts +++ b/src/vs/platform/positronDocs/test/common/positronDocsBundle.vitest.ts @@ -90,6 +90,12 @@ describe('parseManifest', () => { ['malformed JSON', '{ not json'], ['missing version', JSON.stringify({ schema: 1, profile: 'positron', fileCount: 90, docsBaseUrl: 'x', generated: 'y' })], ['non-numeric fileCount', JSON.stringify({ ...JSON.parse(valid), fileCount: 'ninety' })], + // The version becomes a directory name under the cache root, so anything + // that could steer a rename out of it has to be rejected at parse time. + ['a version that escapes the cache root', JSON.stringify({ ...JSON.parse(valid), version: '../../evil' })], + ['a version that is a parent reference', JSON.stringify({ ...JSON.parse(valid), version: '..' })], + ['an absolute version', JSON.stringify({ ...JSON.parse(valid), version: '/etc/passwd' })], + ['a version with a Windows separator', JSON.stringify({ ...JSON.parse(valid), version: '..\\evil' })], ])('rejects %s', (_label, raw) => { expect(parseManifest(raw)).toBeUndefined(); });