Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions product.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions src/positron-dts/positron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3467,6 +3467,53 @@ declare module 'positron' {
): Thenable<string[] | null>;
}

/**
* 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<LocalDocs | undefined>;
}

/**
* Experimental AI features.
*/
Expand Down
7 changes: 7 additions & 0 deletions src/vs/base/common/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
15 changes: 15 additions & 0 deletions src/vs/platform/positronDocs/common/positronDocsCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ILocalDocs | undefined> {
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<ILocalDocs | undefined> {
const resolved = resolveBundleRequest(request);
const state = await this._readState();
Expand Down
101 changes: 101 additions & 0 deletions src/vs/platform/positronDocs/common/positronDocsTriggers.ts
Original file line number Diff line number Diff line change
@@ -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<ILocalDocs | undefined>;
peek(request: IDocsBundleRequest): Promise<ILocalDocs | undefined>;
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<boolean>;
/** 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<void>;
}

/** 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<void> {
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<void> {
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<ILocalDocs | undefined> {
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<RaceOutcome> = 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<RaceOutcome> = 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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();
Expand Down
Loading
Loading