From 6b20055ccfa28c288273a2057f7bd3dcd4f11f3f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:15:13 -0500 Subject: [PATCH 01/68] Attach to existing workers (#DH-22593) --- .gitignore | 2 +- docs/manual-smoke-attach-existing-workers.md | 27 +++ package-lock.json | 61 ++++-- package.json | 6 +- src/dh/ambientModules.d.ts | 19 ++ src/dh/dhe.ts | 98 +++++++-- src/dh/modules.d.ts | 9 - src/services/DheService.ts | 148 ++++++++++--- src/services/ServerManager.ts | 212 ++++++++++++++++--- src/types/serviceTypes.d.ts | 6 +- 10 files changed, 471 insertions(+), 117 deletions(-) create mode 100644 docs/manual-smoke-attach-existing-workers.md create mode 100644 src/dh/ambientModules.d.ts diff --git a/.gitignore b/.gitignore index 161357f41..819f78389 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist node_modules /*.vsix .claude -.tasks +.plans .vscode-test/ .DS_Store e2e-testing/.resources diff --git a/docs/manual-smoke-attach-existing-workers.md b/docs/manual-smoke-attach-existing-workers.md new file mode 100644 index 000000000..f256b8a4c --- /dev/null +++ b/docs/manual-smoke-attach-existing-workers.md @@ -0,0 +1,27 @@ +# Manual Smoke: Attach to Existing Workers (CLI-free) + +Prereqs: a configured Core+ DHE server in extension settings; the ability to +create a worker from a second source (a second VS Code window, the web Query +Monitor, or `dh worker create -p `). + +1. Ensure you have NO running InteractiveConsole workers you own. +2. Click the DHE server in the Deephaven server tree. + -> Login prompt (if needed), then exactly ONE worker is created and + appears as a connected session. (attachable.length === 0 → create path.) +3. From a second source, create another IC worker as the SAME user. + -> Within a couple seconds the new worker auto-attaches under the server + as a second connection. No click required. +4. In an editor connected to the auto-attached worker, run: + from deephaven import empty_table; t = empty_table(5) + -> "t" appears in the variables panel. +5. From the second source, run a script on that worker creating a table. + -> The new variable appears in VS Code's panel (subscribeToFieldUpdates). +6. Disconnect the auto-attached worker's connection from the tree. + -> The connection drops. Verify the worker is STILL ALIVE in the web + Query Monitor (the extension did NOT delete a worker it didn't create). +7. Delete that worker from the second source. + -> Within a couple seconds the connection disappears from the VS Code tree. +8. Disconnect the DHE server. + -> All worker connections drop. The worker the extension auto-created in + step 2 is deleted (owned); any still-running externally-created workers + survive. diff --git a/package-lock.json b/package-lock.json index bc4679aea..a013b203e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "packages/*" ], "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", + "@deephaven-enterprise/auth-nodejs": "^2026.1.38", + "@deephaven-enterprise/query-utils": "^2026.1.38", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -23,7 +23,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.38", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", @@ -556,9 +556,9 @@ } }, "node_modules/@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", + "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", @@ -570,9 +570,9 @@ "link": true }, "node_modules/@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", + "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" @@ -585,11 +585,12 @@ "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", + "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { + "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", @@ -597,6 +598,10 @@ "nanoid": "^5.1.6" } }, + "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven-enterprise/jsapi-types": { + "resolved": "node_modules/@deephaven-enterprise/jsapi-types", + "link": true + }, "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven/jsapi-types": { "version": "1.0.0-dev0.40.9", "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", @@ -15000,9 +15005,9 @@ "dev": true }, "@deephaven-enterprise/auth-nodejs": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-1.20250219.134-beta.tgz", - "integrity": "sha512-INkEml6Al6dQckHhx2gKy53uk2QMb+1IcpL0mu5Z7H7kfRcH7o54KfB9j8DsSYmFZyjdRd1wmUzgfAf8xtxqQA==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", + "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", "requires": { "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/utils": "^0.97.0" @@ -15024,9 +15029,9 @@ } }, "@deephaven-enterprise/jsapi-types": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-1.20250219.134-beta.tgz", - "integrity": "sha512-PRb6Z2J4msFDgooPTcGinml4xcoTc/YYkxK5PVOYV8ax71JXeicsGl4ndIcmgDZwdaciJhvVwhtuY5lMJkmTMg==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", + "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", "requires": { "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" }, @@ -15039,10 +15044,11 @@ } }, "@deephaven-enterprise/query-utils": { - "version": "1.20250219.134-beta", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-1.20250219.134-beta.tgz", - "integrity": "sha512-zkrzFGRgWScDvMA1/tLBE/LtwFDKEoZYSiOdxtgih2+xzP6mgn+VnjWevtcBxPTDJCf6+n7+R+YfGW7TqJtXcw==", + "version": "2026.1.38", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", + "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", "requires": { + "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", @@ -15050,6 +15056,19 @@ "nanoid": "^5.1.6" }, "dependencies": { + "@deephaven-enterprise/jsapi-types": { + "version": "file:node_modules/@deephaven-enterprise/jsapi-types", + "requires": { + "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.9", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", + "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + } + } + }, "@deephaven/jsapi-types": { "version": "1.0.0-dev0.40.9", "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", diff --git a/package.json b/package.json index 1e7374e9b..51897203d 100644 --- a/package.json +++ b/package.json @@ -1125,8 +1125,8 @@ } }, "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^1.20250219.134-beta", - "@deephaven-enterprise/query-utils": "^1.20250219.134-beta", + "@deephaven-enterprise/auth-nodejs": "^2026.1.38", + "@deephaven-enterprise/query-utils": "^2026.1.38", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -1137,7 +1137,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^1.20250219.134-beta", + "@deephaven-enterprise/jsapi-types": "^2026.1.38", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", diff --git a/src/dh/ambientModules.d.ts b/src/dh/ambientModules.d.ts new file mode 100644 index 000000000..490547c8f --- /dev/null +++ b/src/dh/ambientModules.d.ts @@ -0,0 +1,19 @@ +/** + * Ambient module declarations for packages that are referenced by the type + * declarations of our dependencies but are not installed in this project. + * + * IMPORTANT: This file must stay a global *script* — do NOT add any top-level + * `import` or `export`. A `.d.ts` with a top-level import/export becomes a + * *module*, and inside a module `declare module 'x'` is treated as + * *augmentation* of an already-existing module. These modules are not + * installed, so there is nothing to augment; only a script-file *ambient module + * declaration* actually creates the module and lets the import resolve. + */ + +// `@deephaven-enterprise/query-utils@2026.x` ships a `.d.ts` (QueryUtils.d.ts) +// that does `import type { UriVariableDescriptor } from '@deephaven/jsapi-bootstrap'`, +// but does not declare `@deephaven/jsapi-bootstrap` as a dependency and it is +// not installed. Declare a minimal stub so the type import resolves (TS2307). +declare module '@deephaven/jsapi-bootstrap' { + type UriVariableDescriptor = string; +} diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 82c828e45..00c9a2215 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -42,7 +42,7 @@ import { UnsupportedFeatureQueryError, } from '../common'; import { withResolvers } from '../util'; -import type { QuerySerial } from '../shared'; +import { assertDefined, type QuerySerial } from '../shared'; export type IDraftQuery = EditableQueryInfo & { isClientSide: boolean; @@ -411,6 +411,80 @@ export async function getDheAuthConfig( return authConfig; } +/** + * Determine if a given query info represents an attachable IC worker for the + * current effective user. An attachable worker is an InteractiveConsole type, + * owned by `operateAs`, and currently Running. + * @param queryInfo Query info to check. + * @param operateAs The effective user to match ownership against. + * @returns True if the query is attachable, false otherwise. + */ +export function isAttachableWorker( + queryInfo: QueryInfo, + operateAs: string +): boolean { + return ( + queryInfo.type === INTERACTIVE_CONSOLE_QUERY_TYPE && + queryInfo.owner === operateAs && + queryInfo.designated?.status === 'Running' + ); +} + +/** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @param dheClient DHE client to use. + * @returns A promise resolving to the filtered QueryInfo array. + */ +export async function listAttachableWorkers( + dheClient: DheAuthenticatedClient +): Promise { + const userInfo = await dheClient.getUserInfo(); + const operateAs = userInfo.operateAs; + return dheClient + .getKnownConfigs() + .filter(qi => isAttachableWorker(qi, operateAs)); +} + +/** + * Build WorkerInfo from an already-Running QueryInfo without any I/O or + * side effects. Returns undefined when `queryInfo.designated` is null. + * @param tagId Unique tag id to associate with the worker. + * @param queryInfo The running query info. + * @returns WorkerInfo or undefined. + */ +export function buildWorkerInfo( + tagId: UniqueID, + queryInfo: QueryInfo +): WorkerInfo | undefined { + if (queryInfo.designated == null) { + return; + } + + assertDefined( + queryInfo.designated.ideUrl, + 'designated.ideUrl must be defined' + ); + + const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = + queryInfo.designated; + + const workerUrl = new URL(jsApiUrl) as WorkerURL; + workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); + + return { + tagId, + serial: queryInfo.serial as QuerySerial, + envoyPrefix, + grpcUrl: new URL(grpcUrl) as GrpcURL, + ideUrl: new URL(ideUrl) as IdeURL, + jsapiUrl: new URL(jsApiUrl) as JsapiURL, + processInfoId, + workerName, + workerUrl, + }; +} + /** * Search existing queries for a query with the given tag id and return its serial. * @param tagId Unique tag id to search for. @@ -498,27 +572,7 @@ export async function getWorkerInfoFromQuery( } } - if (queryInfo.designated == null) { - return; - } - - const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = - queryInfo.designated; - - const workerUrl = new URL(jsApiUrl) as WorkerURL; - workerUrl.pathname = workerUrl.pathname.replace(/jsapi\/dh-core.js$/, ''); - - return { - tagId, - serial: querySerial, - envoyPrefix, - grpcUrl: new URL(grpcUrl) as GrpcURL, - ideUrl: new URL(ideUrl) as IdeURL, - jsapiUrl: new URL(jsApiUrl) as JsapiURL, - processInfoId, - workerName, - workerUrl, - }; + return buildWorkerInfo(tagId, queryInfo); } /** diff --git a/src/dh/modules.d.ts b/src/dh/modules.d.ts index d7de75a7b..ea6f96fce 100644 --- a/src/dh/modules.d.ts +++ b/src/dh/modules.d.ts @@ -1,12 +1,3 @@ -/** - * Augment types that are missing in current jsapi-types. - */ -declare module '@deephaven-enterprise/jsapi-types' { - interface EnterpriseClient { - deleteQueries(querySerials: string[]): Promise; - } -} - export {}; /** diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 79b504668..a9dfe02aa 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -18,14 +18,17 @@ import { type WorkerInfo, type WorkerURL, } from '../types'; -import { Logger, URLMap } from '../util'; +import { Logger, uniqueId, URLMap } from '../util'; import { + buildWorkerInfo, createInteractiveConsoleQuery, createQueryName, deleteQueries, getDheFeatures, getSerialFromTagId, getWorkerInfoFromQuery, + isAttachableWorker, + listAttachableWorkers as listAttachableWorkersHelper, } from '../dh/dhe'; import { CLOSE_CREATE_QUERY_VIEW_CMD, @@ -101,6 +104,8 @@ export class DheService implements IDheService { private _clientPromise: Promise | null = null; private _isConnected: boolean = false; + private _operateAs: string | null = null; + private _removeConfigListeners: (() => void) | null = null; private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; @@ -110,8 +115,12 @@ export class DheService implements IDheService { private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; - private readonly _onDidWorkerTerminate = new vscode.EventEmitter(); - readonly onDidWorkerTerminate = this._onDidWorkerTerminate.event; + private readonly _onDidWorkerAttachable = + new vscode.EventEmitter(); + readonly onDidWorkerAttachable = this._onDidWorkerAttachable.event; + + private readonly _onDidWorkerRemoved = new vscode.EventEmitter(); + readonly onDidWorkerRemoved = this._onDidWorkerRemoved.event; readonly serverUrl: URL; @@ -141,7 +150,9 @@ export class DheService implements IDheService { const maybeClient = await this._dheClientCache.get(this.serverUrl); if (maybeClient != null) { - this._subscribeToWorkerTermination(maybeClient); + const userInfo = await maybeClient.client.getUserInfo(); + this._operateAs = userInfo.operateAs; + await this._subscribeToWorkerEvents(maybeClient); } if (!this._dheServerFeaturesCache.has(this.serverUrl)) { @@ -177,7 +188,18 @@ export class DheService implements IDheService { }; private _onDidDheClientCacheInvalidate = (url: URL): void => { - if (url.toString() === this.serverUrl.toString()) { + // Only reset when the client was actually removed from the cache (logout / + // disconnect / failed login). The cache also fires `onDidChange` on `set`, + // which happens during a successful login from inside `_initClient` itself. + // Resetting on that `set` would null out the in-flight `_clientPromise` we + // are currently awaiting, so a subsequent `getClient(false)` (e.g. from + // `listAttachableWorkers`) would see `null` and short-circuit — silently + // skipping the attach path. Guard on the client being absent so we only + // reset for genuine invalidations. + if ( + url.toString() === this.serverUrl.toString() && + !this._dheClientCache.has(this.serverUrl) + ) { // Reset the client promise so that the next call to `getClient` can // reinitialize it if necessary. this._clientPromise = null; @@ -185,37 +207,70 @@ export class DheService implements IDheService { }; /** - * Subscribe to DHE config updates to detect when workers enter a terminal state. + * Subscribe to DHE config added/updated/removed events. Emits + * `onDidWorkerAttachable` when an IC worker becomes attachable, and + * `onDidWorkerRemoved` when a tracked worker is removed or enters a terminal + * state. Replaces any previous subscription. * @param dheClient DHE client to use. */ - private _subscribeToWorkerTermination = async ( + private _subscribeToWorkerEvents = async ( dheClient: DheAuthenticatedClientWrapper ): Promise => { + // Remove any previous listeners before re-registering. + this._removeConfigListeners?.(); + const dhe = await this._dheJsApiCache.get(this.serverUrl); + const operateAs = this._operateAs ?? ''; - dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_UPDATED, - ({ detail: queryInfo }: CustomEvent) => { - const status = queryInfo.designated?.status; - if (!isTerminalQueryStatus(status)) { - return; - } + const onConfigAddedOrUpdated = ({ + detail: queryInfo, + }: CustomEvent): void => { + const status = queryInfo.designated?.status; - const workerInfo = [...this._workerInfoMap.values()].find( + if (isTerminalQueryStatus(status)) { + // Terminal status on a tracked worker → signal removal. + const isTracked = [...this._workerInfoMap.values()].some( w => w.serial === queryInfo.serial ); - - if (workerInfo == null) { - return; + if (isTracked) { + logger.info('Worker entered terminal state:', queryInfo.serial); + this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } + } else if (isAttachableWorker(queryInfo, operateAs)) { + this._onDidWorkerAttachable.fire(queryInfo); + } + }; - logger.info( - 'Worker entered terminal state:', - workerInfo.workerUrl.href - ); - this._onDidWorkerTerminate.fire(workerInfo.workerUrl); + const onConfigRemoved = ({ + detail: queryInfo, + }: CustomEvent): void => { + const isTracked = [...this._workerInfoMap.values()].some( + w => w.serial === queryInfo.serial + ); + if (isTracked) { + logger.info('Worker removed:', queryInfo.serial); + this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } + }; + + const removeAdded = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_ADDED, + onConfigAddedOrUpdated ); + const removeUpdated = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_UPDATED, + onConfigAddedOrUpdated + ); + const removeRemoved = dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_REMOVED, + onConfigRemoved + ); + + this._removeConfigListeners = (): void => { + removeAdded(); + removeUpdated(); + removeRemoved(); + }; }; /** @@ -371,7 +426,41 @@ export class DheService implements IDheService { }; /** - * Delete a worker. + * Attach to a pre-existing Running worker by building WorkerInfo from the + * given QueryInfo. Does NOT add the serial to `_querySerialSet` — attached + * workers are never owned and must never be deleted by the extension. + * @param queryInfo The already-Running QueryInfo for the worker to attach. + * @returns WorkerInfo for the attached worker. + */ + attachWorker = (queryInfo: QueryInfo): WorkerInfo => { + const tagId = uniqueId(); + const workerInfo = buildWorkerInfo(tagId, queryInfo); + if (workerInfo == null) { + throw new Error( + `Cannot attach worker: queryInfo.designated is null for serial ${queryInfo.serial}` + ); + } + this._workerInfoMap.set(workerInfo.workerUrl, workerInfo); + return workerInfo; + }; + + /** + * List all running InteractiveConsole workers owned by the current effective + * user. + * @returns A promise resolving to the filtered QueryInfo array. + */ + listAttachableWorkers = async (): Promise => { + const dheClient = await this.getClient(false); + if (dheClient == null) { + return []; + } + return listAttachableWorkersHelper(dheClient.client); + }; + + /** + * Delete a worker. Only deletes the server-side PQ when the worker is owned + * by this extension (serial is in `_querySerialSet`). Attached workers are + * removed from `_workerInfoMap` but the PQ is left running. * @param workerUrl Worker URL to delete. */ deleteWorker = async (workerUrl: WorkerURL): Promise => { @@ -380,10 +469,14 @@ export class DheService implements IDheService { return; } + const isOwned = this._querySerialSet.has(workerInfo.serial); + this._querySerialSet.delete(workerInfo.serial); this._workerInfoMap.delete(workerUrl); - await this._disposeQueries([workerInfo.serial]); + if (isOwned) { + await this._disposeQueries([workerInfo.serial]); + } }; getQuerySerialFromTag = async ( @@ -407,7 +500,10 @@ export class DheService implements IDheService { const querySerials = [...this._querySerialSet]; this._querySerialSet.clear(); - this._onDidWorkerTerminate.dispose(); + this._removeConfigListeners?.(); + this._removeConfigListeners = null; + this._onDidWorkerAttachable.dispose(); + this._onDidWorkerRemoved.dispose(); await Promise.all([ this._workerInfoMap.dispose(), diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 334b5029c..afc1f08ed 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { randomUUID } from 'node:crypto'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import { QueryCreationCancelledError, QueryStartupFailureError, @@ -35,6 +36,7 @@ import { } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; +import type { QuerySerial } from '../shared'; import { isDhcServerRunning } from '../dh/dhc'; const logger = new Logger('ServerManager'); @@ -69,6 +71,8 @@ export class ServerManager implements IServerManager { void this.loadServerConfig(); } + private readonly _attachedWorkerSerials: Map = + new Map(); private readonly _configService: IConfigService; private readonly _connectionMap: URLMap; private readonly _pendingConnectionMap: URLMap< @@ -256,8 +260,9 @@ export class ServerManager implements IServerManager { let placeholderUrl: URL | undefined; if (serverState.type === 'DHE') { - const isNewDheService = !this._dheServiceCache.has(serverUrl); - const dheService = await this._dheServiceCache.get(serverUrl); + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); // Get client. Client will be initialized if it doesn't exist (including // prompting user for login). @@ -265,29 +270,76 @@ export class ServerManager implements IServerManager { return null; } - // Handle workers stopped externally (i.e. web-client Query Monitor) - if (isNewDheService) { - dheService.onDidWorkerTerminate(workerUrl => { - this.disconnectFromServer(workerUrl); + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, }); + this._onDidUpdate.fire(); } - // The `serverUrl` in this block is for the DHE server but gets set to the - // newly created worker url before leaving the block, so we need to update - // the connection count while we still have the reference. - this.updateConnectionCount(serverUrl, 1); - tagId = uniqueId(); + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onDidWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onDidWorkerRemoved(serial => this._reconcileDetach(serial)); + } - // Put a placeholder connection in place until the worker is ready. - placeholderUrl = this.addWorkerPlaceholderConnection(serverUrl, tagId); + // Snapshot currently-running attachable workers and compute the + // not-yet-attached subset. + const attachable = await dheService.listAttachableWorkers(); + const toAttach = attachable.filter( + qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) + ); + + if (toAttach.length > 0) { + // Attach existing workers in batches to avoid server stampede. + const BATCH_SIZE = 4; + let firstConnection: ConnectionState | null = null; + for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { + const batch = toAttach.slice(i, i + BATCH_SIZE); + const connections = await Promise.all( + batch.map(async qi => { + const workerInfo = dheService.attachWorker(qi); + return this._connectWorker(dheServerUrl, workerInfo); + }) + ); + if (firstConnection == null) { + firstConnection = connections.find(c => c != null) ?? null; + } + } + if (toAttach.length > 1) { + this._toaster.info(`Attached to ${toAttach.length} worker(s).`); + } + return firstConnection; + } + + // Nothing left to attach — either no workers exist for this user, or all + // of them already have live connections. Create a fresh one so clicking a + // server always yields a usable session. + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheServerUrl, + tagId + ); let workerInfo: WorkerInfo; try { workerInfo = await dheService.createWorker(tagId, workerConsoleType); - // If the worker finished creating, but there is no placeholder connection, - // this indicates that the user cancelled the creation before it was ready. - // In this case, dispose of the worker. + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. if (!this._connectionMap.has(placeholderUrl)) { dheService.deleteWorker(workerInfo.workerUrl); this._onDidUpdate.fire(); @@ -309,33 +361,20 @@ export class ServerManager implements IServerManager { this._toaster.error(msg); } - this.updateConnectionCount(serverUrl, -1); - this._connectionMap.delete(placeholderUrl); + this.removeWorkerPlaceholderConnection(placeholderUrl); return null; } - // Map the worker URL to the server URL to make things easier to dispose - // later. - this._workerURLToServerURLMap.set( - new URL(workerInfo.workerUrl), - serverUrl - ); - - // Update the server URL to the worker url to be used below with core - // connection creation. - serverUrl = new URL(workerInfo.workerUrl); + this.removeWorkerPlaceholderConnection(placeholderUrl); + return this._connectWorker(dheServerUrl, workerInfo); } - const connection = this._dhcServiceFactory.create(serverUrl, tagId); + // DHC path: single direct connection. + const connection = this._dhcServiceFactory.create(serverUrl, undefined); // Initialize client + prompt for login if necessary const coreClient = await connection.getClient(); - // Cleanup placeholder connection if one exists - if (placeholderUrl) { - this.removeWorkerPlaceholderConnection(placeholderUrl); - } - if (coreClient == null) { return null; } @@ -368,6 +407,102 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(serverUrl) ?? null; }; + /** + * Create a Core+ JS API connection to an existing worker. Populates + * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` + * for idempotency/teardown. Used by both the create path and the attach path. + * Does NOT touch placeholder connections — that is the create path's concern. + * @param dheServerUrl The DHE server this worker belongs to. + * @param workerInfo Worker info built from the query. + * @returns The new connection state, or null on failure. + */ + private _connectWorker = async ( + dheServerUrl: URL, + workerInfo: WorkerInfo + ): Promise => { + const workerUrl = new URL(workerInfo.workerUrl); + + // Idempotency gate: reserve the serial synchronously, before any `await`, + // so concurrent attach attempts for the same worker — e.g. the initial + // enumeration racing a streaming config event, or two clicks on the same + // server — cannot both connect and double-count. A later failure rolls the + // reservation back so the worker can be retried. + if (this._attachedWorkerSerials.has(workerInfo.serial)) { + return this._connectionMap.get(workerUrl) ?? null; + } + this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); + + // Map the worker URL to its DHE server so the auth flow can resolve creds. + this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + + const connection = this._dhcServiceFactory.create( + workerUrl, + workerInfo.tagId + ); + + // Initialize client (drives getWorkerCredentials → createAuthToken). + const coreClient = await connection.getClient(); + if (coreClient == null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + return null; + } + + this._connectionMap.set(workerUrl, connection); + this._onDidUpdate.fire(); + + if (!(await connection.initSession())) { + this._attachedWorkerSerials.delete(workerInfo.serial); + this._coreClientCache.delete(workerUrl); + connection.dispose(); + this._connectionMap.delete(workerUrl); + return null; + } + + connection.onDidDisconnect(() => { + logger.debug('onDidDisconnect fired for:', workerUrl.href); + this.disconnectFromServer(workerUrl); + }); + + connection.onDidChangeRunningCodeStatus?.(() => { + this._onDidUpdate.fire(); + }); + + this.updateConnectionCount(dheServerUrl, 1); + + this._onDidConnect.fire(workerUrl); + this._onDidUpdate.fire(); + + return this._connectionMap.get(workerUrl) ?? null; + }; + + /** + * Attach a single worker when a config event indicates it became attachable. + * Idempotent: no-ops if the serial is already connected. + */ + private _reconcileAttach = async ( + dheServerUrl: URL, + dheService: IDheService, + queryInfo: QueryInfo + ): Promise => { + if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { + return; + } + const workerInfo = dheService.attachWorker(queryInfo); + await this._connectWorker(dheServerUrl, workerInfo); + }; + + /** + * Detach a worker when a config event indicates it was removed or died. + * No-ops if the serial is not tracked. + */ + private _reconcileDetach = async (serial: QuerySerial): Promise => { + const workerUrl = this._attachedWorkerSerials.get(serial); + if (workerUrl == null) { + return; + } + await this.disconnectFromServer(workerUrl); + }; + /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. * @param serverUrl The DHE server URL the pending worker is associated with. @@ -459,6 +594,15 @@ export class ServerManager implements IServerManager { await dheService.deleteWorker(serverOrWorkerUrl as WorkerURL); } + // Clear the idempotency/teardown gate so a later reconnect can re-attach. + const urlStr = serverOrWorkerUrl.toString(); + for (const [serial, url] of this._attachedWorkerSerials.entries()) { + if (url.toString() === urlStr) { + this._attachedWorkerSerials.delete(serial); + break; + } + } + const connection = this._connectionMap.get(serverOrWorkerUrl); if (connection == null) { return; diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 3e7f2890b..3d56a47b1 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConsoleType, CoreConnectionConfig, @@ -80,7 +81,8 @@ export interface IDhcService extends IDisposable, ConnectionState { } export interface IDheService extends ConnectionState, IDisposable { - readonly onDidWorkerTerminate: vscode.Event; + readonly onDidWorkerAttachable: vscode.Event; + readonly onDidWorkerRemoved: vscode.Event; getClient( initializeIfNull: false @@ -92,6 +94,8 @@ export interface IDheService extends ConnectionState, IDisposable { getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; + attachWorker: (queryInfo: QueryInfo) => WorkerInfo; + listAttachableWorkers: () => Promise; createWorker: ( tagId: UniqueID, consoleType?: ConsoleType From 89aac333a9f67e683b025e93abe3557914544434 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 11:54:21 -0500 Subject: [PATCH 02/68] Attaching to workers (#DH-22593) --- docs/manual-smoke-attach-existing-workers.md | 27 ------------ src/dh/dhe.ts | 1 + .../ServerConnectionPanelTreeProvider.ts | 7 ++- src/providers/ServerConnectionTreeProvider.ts | 14 +++++- src/services/DheService.ts | 44 ++++++++++++++++++- src/types/commonTypes.d.ts | 2 + src/util/treeViewUtils.ts | 10 +++-- 7 files changed, 71 insertions(+), 34 deletions(-) delete mode 100644 docs/manual-smoke-attach-existing-workers.md diff --git a/docs/manual-smoke-attach-existing-workers.md b/docs/manual-smoke-attach-existing-workers.md deleted file mode 100644 index f256b8a4c..000000000 --- a/docs/manual-smoke-attach-existing-workers.md +++ /dev/null @@ -1,27 +0,0 @@ -# Manual Smoke: Attach to Existing Workers (CLI-free) - -Prereqs: a configured Core+ DHE server in extension settings; the ability to -create a worker from a second source (a second VS Code window, the web Query -Monitor, or `dh worker create -p `). - -1. Ensure you have NO running InteractiveConsole workers you own. -2. Click the DHE server in the Deephaven server tree. - -> Login prompt (if needed), then exactly ONE worker is created and - appears as a connected session. (attachable.length === 0 → create path.) -3. From a second source, create another IC worker as the SAME user. - -> Within a couple seconds the new worker auto-attaches under the server - as a second connection. No click required. -4. In an editor connected to the auto-attached worker, run: - from deephaven import empty_table; t = empty_table(5) - -> "t" appears in the variables panel. -5. From the second source, run a script on that worker creating a table. - -> The new variable appears in VS Code's panel (subscribeToFieldUpdates). -6. Disconnect the auto-attached worker's connection from the tree. - -> The connection drops. Verify the worker is STILL ALIVE in the web - Query Monitor (the extension did NOT delete a worker it didn't create). -7. Delete that worker from the second source. - -> Within a couple seconds the connection disappears from the VS Code tree. -8. Disconnect the DHE server. - -> All worker connections drop. The worker the extension auto-created in - step 2 is deleted (owned); any still-running externally-created workers - survive. diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 00c9a2215..dc89450ce 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -479,6 +479,7 @@ export function buildWorkerInfo( grpcUrl: new URL(grpcUrl) as GrpcURL, ideUrl: new URL(ideUrl) as IdeURL, jsapiUrl: new URL(jsApiUrl) as JsapiURL, + name: queryInfo.name, processInfoId, workerName, workerUrl, diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 6f602be36..b1f0a10cc 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -38,10 +38,15 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase void) | null = null; + private readonly _creatingTagIds = new Set(); private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; @@ -236,7 +237,21 @@ export class DheService implements IDheService { logger.info('Worker entered terminal state:', queryInfo.serial); this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); } - } else if (isAttachableWorker(queryInfo, operateAs)) { + } else if ( + isAttachableWorker(queryInfo, operateAs) && + !this._querySerialSet.has(queryInfo.serial as QuerySerial) && + !this._isCreatingQuery(queryInfo.name) + ) { + // Skip workers the extension is creating or already owns. The create + // path connects those directly with the tagId it allocated for the + // query name (`IC - VS Code - `). Without this guard, the same + // `EVENT_CONFIG_UPDATED` that flips a freshly-created worker to + // `Running` would auto-attach it here, racing the create path: it would + // double-connect with a *new* random tagId and let the now-functioning + // detach/cleanup machinery tear down a worker mid-creation (surfacing as + // a spurious startup failure). `_querySerialSet` covers the worker once + // its serial is known; `_isCreatingQuery` covers the earlier window + // (e.g. the iframe flow) where only the query name is known (Gotcha 4). this._onDidWorkerAttachable.fire(queryInfo); } }; @@ -328,6 +343,24 @@ export class DheService implements IDheService { return dheClient; } + /** + * Whether the given query name belongs to a worker this extension is + * currently creating. Created workers are named `IC - VS Code - ` + * (the iframe create flow may append a `_vN` version suffix), so a prefix + * match against in-flight tagIds covers both forms. Used to keep the + * auto-attach event path off a worker the create path owns end-to-end, during + * the window before its serial is known and added to `_querySerialSet`. + * @param queryName The query name from a config event. + */ + private _isCreatingQuery = (queryName: string): boolean => { + for (const tagId of this._creatingTagIds) { + if (queryName.startsWith(createQueryName(tagId))) { + return true; + } + } + return false; + }; + /** * Create an InteractiveConsole query and get worker info from it. * @param tagId Unique tag id to include in the worker info. @@ -355,6 +388,14 @@ export class DheService implements IDheService { let startupFailureStatus: string | null = null; const queryName = createQueryName(tagId); + + // Suppress auto-attach for this worker while it is being created. The + // config event that flips it to `Running` would otherwise race the create + // path and auto-attach it (see `onDidWorkerAttachable` guard). Cleared in + // the `finally` below, by which point its serial is in `_querySerialSet` + // (added synchronously right after, with no `await` in between), so the + // serial-based guard takes over without a gap. + this._creatingTagIds.add(tagId); const removeStartupFailureListener = dheClient.client.addEventListener( dhe.Client.EVENT_CONFIG_UPDATED, ({ detail: queryInfo }: CustomEvent) => { @@ -403,6 +444,7 @@ export class DheService implements IDheService { throw err; } finally { removeStartupFailureListener(); + this._creatingTagIds.delete(tagId); } if (querySerial == null) { diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index de54d9051..0c79e1cb7 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -185,6 +185,8 @@ export interface WorkerInfo { grpcUrl: GrpcURL; ideUrl: IdeURL; jsapiUrl: JsapiURL; + /** Persistent query name (`queryInfo.name`), as shown in the Query Monitor. */ + name: string; processInfoId: string | null; serial: QuerySerial; workerName: string | null; diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 686ed10c9..e71482b07 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -58,7 +58,8 @@ export async function getPanelConnectionTreeItem( getConsoleType: ( connection: ConnectionState ) => Promise, - serverLabel?: string + serverLabel?: string, + pqName?: string ): Promise { const descriptionTokens: string[] = []; @@ -68,8 +69,11 @@ export async function getPanelConnectionTreeItem( descriptionTokens.push(consoleType); } - if (connection.tagId) { - descriptionTokens.push(connection.tagId); + // Prefer the persistent query name (what the DHE Query Monitor shows) over + // the local correlation tagId. Falls back to tagId for plain DHC connections. + const idToken = pqName ?? connection.tagId; + if (idToken) { + descriptionTokens.push(idToken); } const label = serverLabel ?? connection.serverUrl.host; From 1abc83ad18239191844be4e60efb75d8eb6c6907 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 17:22:00 -0500 Subject: [PATCH 03/68] Moved language identifier to icon instead of description (#DH-22593) --- package.json | 2 +- src/providers/ServerConnectionTreeProvider.ts | 33 ++++++++------- .../__snapshots__/treeViewUtils.spec.ts.snap | 6 +-- src/util/treeViewUtils.ts | 40 +++++++++++++------ 4 files changed, 47 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 51897203d..deec151a8 100644 --- a/package.json +++ b/package.json @@ -1096,7 +1096,7 @@ }, { "id": "vscode-deephaven.view.serverConnectionTree", - "name": "Connections", + "name": "Workers", "type": "tree", "icon": "images/dh-logo-28.svg" }, diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index f55f61fa8..12cbd8e72 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -4,9 +4,14 @@ import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; import type { IDhcService, ConnectionState, + ConsoleType, ServerConnectionNode, } from '../types'; -import { isInstanceOf, sortByStringProp } from '../util'; +import { + getConsoleTypeIconId, + isInstanceOf, + sortByStringProp, +} from '../util'; import { DhcService } from '../services'; import { getServerMatchPortIfLocalHost } from '../mcp/utils'; @@ -31,16 +36,13 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase should return panel connection tree item: exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": "python", + "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", @@ -39,10 +39,10 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": "python", + "description": undefined, "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, "label": "localhost:10000", } diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index e71482b07..c60ba216d 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -48,6 +48,26 @@ export function getVariableIconPath( } } +/** + * Get the icon id for a console type / language, used for connection tree nodes. + * Falls back to the generic "connected" icon when the console type is unknown + * (e.g. a plain DHC connection or one whose console type has not resolved yet). + * @param consoleType Console type (language) of the connection, if known. + * @returns Icon id from `ICON_ID`. + */ +export function getConsoleTypeIconId( + consoleType: ConsoleType | undefined +): string { + switch (consoleType) { + case 'python': + return ICON_ID.python; + case 'groovy': + return ICON_ID.groovy; + default: + return ICON_ID.connected; + } +} + /** * Get `TreeItem` for a panel connection. * @param connection Connection state @@ -61,31 +81,25 @@ export async function getPanelConnectionTreeItem( serverLabel?: string, pqName?: string ): Promise { - const descriptionTokens: string[] = []; - + // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); - if (consoleType) { - descriptionTokens.push(consoleType); - } - // Prefer the persistent query name (what the DHE Query Monitor shows) over // the local correlation tagId. Falls back to tagId for plain DHC connections. - const idToken = pqName ?? connection.tagId; - if (idToken) { - descriptionTokens.push(idToken); - } + const description = pqName ?? connection.tagId; const label = serverLabel ?? connection.serverUrl.host; - const description = - descriptionTokens.length === 0 ? undefined : descriptionTokens.join(' - '); return { label, description, collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + // Show the language (Python/Groovy) icon when idle/connected; show the + // spinner while busy (connecting or running code). iconPath: new vscode.ThemeIcon( - connection.isConnected ? ICON_ID.connected : ICON_ID.connecting + connection.isRunningCode || !connection.isConnected + ? ICON_ID.connecting + : getConsoleTypeIconId(consoleType) ), }; } From ea1970a91480c4b997ae3266c550ac16a4465d27 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 4 Jun 2026 18:31:48 -0500 Subject: [PATCH 04/68] Added server parent nodes in connections + panels views (#DH-22593) --- src/common/constants.ts | 1 + ...rverConnectionTreeDragAndDropController.ts | 11 ++- .../ServerConnectionPanelTreeProvider.ts | 53 +++++++---- src/providers/ServerConnectionTreeProvider.ts | 83 +++++++++++------ src/services/ServerManager.ts | 17 ++++ src/types/serviceTypes.d.ts | 8 ++ src/types/treeViewTypes.d.ts | 3 +- .../__snapshots__/treeViewUtils.spec.ts.snap | 88 +++++++++++++++++-- src/util/treeViewUtils.spec.ts | 48 ++++++++-- src/util/treeViewUtils.ts | 82 ++++++++++++++++- 10 files changed, 328 insertions(+), 66 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index 3506aa0ce..b7112fec8 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -159,6 +159,7 @@ export const VARIABLE_UNICODE_ICONS = { export const CONNECTION_TREE_ITEM_CONTEXT = { isConnectionConnected: 'isConnectionConnected', isConnectionConnecting: 'isConnectionConnecting', + isDHEServerConnectionParent: 'isDHEServerConnectionParent', isUri: 'isUri', } as const; diff --git a/src/controllers/ServerConnectionTreeDragAndDropController.ts b/src/controllers/ServerConnectionTreeDragAndDropController.ts index 9ed518904..6ff4f5704 100644 --- a/src/controllers/ServerConnectionTreeDragAndDropController.ts +++ b/src/controllers/ServerConnectionTreeDragAndDropController.ts @@ -1,5 +1,5 @@ import { MIME_TYPE } from '../common'; -import { getEditorForUri } from '../util'; +import { getEditorForUri, isServerStateNode } from '../util'; import type { IServerManager } from '../types'; import type { ServerConnectionNode } from '../types/treeViewTypes'; @@ -21,8 +21,13 @@ export class ServerConnectionTreeDragAndDropController dataTransfer: vscode.DataTransfer, _token: vscode.CancellationToken ): Promise => { - // Only target connection nodes - if (target == null || target instanceof vscode.Uri) { + // Only target connection nodes. DHE server group nodes and uri leaf nodes + // are not valid editor-connection targets. + if ( + target == null || + target instanceof vscode.Uri || + isServerStateNode(target) + ) { return; } diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index b1f0a10cc..603137ffe 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -2,13 +2,15 @@ import * as vscode from 'vscode'; import type { IPanelService, IServerManager, - ConnectionState, ServerConnectionPanelNode, } from '../types'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { + getConnectionServerTreeItem, + getConnectionTreeRootNodes, getPanelConnectionTreeItem, getPanelVariableTreeItem, + isServerStateNode, sortByStringProp, } from '../util'; import { getFirstSupportedConsoleType } from '../services'; @@ -27,40 +29,61 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase => { - if (Array.isArray(connectionOrVariable)) { - return getPanelVariableTreeItem(connectionOrVariable); + // Variable leaf node. + if (Array.isArray(node)) { + return getPanelVariableTreeItem(node); + } + + // DHE server node grouping its worker connections. + if (isServerStateNode(node)) { + return getConnectionServerTreeItem(node); } const serverLabel = getServerMatchPortIfLocalHost( this.serverManager, - connectionOrVariable.serverUrl + node.serverUrl )?.label; - const workerInfo = await this.serverManager.getWorkerInfo( - connectionOrVariable.serverUrl - ); + const workerInfo = await this.serverManager.getWorkerInfo(node.serverUrl); + + // DHE worker nodes are nested under their server node (worker name as the + // label); flat DHC connections keep the server label. + const isWorkerChild = this.serverManager.getServerForConnection(node) != null; return getPanelConnectionTreeItem( - connectionOrVariable, + node, getFirstSupportedConsoleType, serverLabel, - workerInfo?.name + workerInfo?.name, + isWorkerChild ); }; getChildren = ( - connectionOrRoot?: ConnectionState + elementOrRoot?: ServerConnectionPanelNode ): vscode.ProviderResult => { - if (connectionOrRoot == null) { + // Root: DHE server nodes + flat DHC connection nodes. + if (elementOrRoot == null) { + return getConnectionTreeRootNodes(this.serverManager); + } + + // Variable leaf nodes have no children. + if (Array.isArray(elementOrRoot)) { + return []; + } + + // DHE server node -> its worker connections. + if (isServerStateNode(elementOrRoot)) { return this.serverManager - .getConnections() + .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); } - return [...this._panelService.getVariables(connectionOrRoot.serverUrl)] + // Connection node -> its panel variables. + return [...this._panelService.getVariables(elementOrRoot.serverUrl)] .sort(sortByStringProp('title')) - .map(variable => [connectionOrRoot.serverUrl, variable]); + .map(variable => [elementOrRoot.serverUrl, variable]); }; } diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index 12cbd8e72..c7c10868a 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -1,15 +1,13 @@ import * as vscode from 'vscode'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; -import type { - IDhcService, - ConnectionState, - ConsoleType, - ServerConnectionNode, -} from '../types'; +import type { ConsoleType, ServerConnectionNode } from '../types'; import { + getConnectionServerTreeItem, + getConnectionTreeRootNodes, getConsoleTypeIconId, isInstanceOf, + isServerStateNode, sortByStringProp, } from '../util'; import { DhcService } from '../services'; @@ -20,29 +18,31 @@ import { getServerMatchPortIfLocalHost } from '../mcp/utils'; */ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { getTreeItem = async ( - connectionOrUri: ServerConnectionNode + node: ServerConnectionNode ): Promise => { // Uri node associated with a parent connection node - if (connectionOrUri instanceof vscode.Uri) { + if (node instanceof vscode.Uri) { return { - description: connectionOrUri.path, + description: node.path, contextValue: CONNECTION_TREE_ITEM_CONTEXT.isUri, command: { command: 'vscode.open', title: 'Open Uri', - arguments: [connectionOrUri], + arguments: [node], }, - resourceUri: connectionOrUri, + resourceUri: node, }; } + // DHE server node grouping its worker connections. + if (isServerStateNode(node)) { + return getConnectionServerTreeItem(node); + } + // Console type (language) drives the node icon rather than the description. let consoleType: ConsoleType | undefined; - if ( - isInstanceOf(connectionOrUri, DhcService) && - connectionOrUri.isInitialized - ) { - [consoleType] = await connectionOrUri.getConsoleTypes(); + if (isInstanceOf(node, DhcService) && node.isInitialized) { + [consoleType] = await node.getConsoleTypes(); } // Prefer the persistent query name (what the DHE Query Monitor shows) over @@ -51,25 +51,33 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase => { + // Root: DHE server nodes + flat DHC connection nodes. if (elementOrRoot == null) { + return getConnectionTreeRootNodes(this.serverManager); + } + + // Uri leaf nodes have no children. + if (elementOrRoot instanceof vscode.Uri) { + return []; + } + + // DHE server node -> its worker connections. + if (isServerStateNode(elementOrRoot)) { return this.serverManager - .getConnections() + .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); } + // Connection node -> its editor uris. return this.serverManager.getConnectionUris(elementOrRoot); }; @@ -102,11 +122,16 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { + getParent = (element: ServerConnectionNode): ServerConnectionNode | null => { if (element instanceof vscode.Uri) { return this.serverManager.getUriConnection(element); } - return null; + if (isServerStateNode(element)) { + return null; + } + + // Connection node -> its parent DHE server (or null for flat DHC). + return this.serverManager.getServerForConnection(element) ?? null; }; } diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index afc1f08ed..b5da5942a 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -736,6 +736,23 @@ export class ServerManager implements IServerManager { }); }; + /** + * Get the parent DHE server for a connection. Returns the DHE server state + * when the connection is a DHE worker (its `serverUrl` is mapped to a DHE + * server URL), or `undefined` for DHC connections, which have no parent + * server node in the tree. + * @param connection The connection to get the parent server for. + * @returns The parent DHE server state, or `undefined`. + */ + getServerForConnection = ( + connection: ConnectionState + ): ServerState | undefined => { + const dheServerUrl = this._workerURLToServerURLMap.get( + connection.serverUrl + ); + return dheServerUrl == null ? undefined : this.getServer(dheServerUrl); + }; + /** * Get all URIs associated with a connection. * @param connection diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 3d56a47b1..25080dd9d 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -190,6 +190,14 @@ export interface IServerManager extends IDisposable { getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; + /** + * Get the parent DHE server for a connection. Returns the DHE `ServerState` + * when the connection is a DHE worker, or `undefined` for DHC connections + * (which have no parent server node in the tree). + */ + getServerForConnection: ( + connection: ConnectionState + ) => ServerState | undefined; getDheServiceForWorker: (maybeWorkerUrl: URL) => Promise; getEditorConnection: (uri: vscode.Uri) => Promise; getWorkerCredentials: ( diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index 7f437f3c4..dd31e1a49 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -9,12 +9,13 @@ export type ServerGroupState = 'Managed' | 'Running' | 'Stopped'; export type ServerNode = ServerGroupState | ServerState; export interface ServerTreeView extends vscode.TreeView {} -export type ServerConnectionNode = ConnectionState | vscode.Uri; +export type ServerConnectionNode = ServerState | ConnectionState | vscode.Uri; export interface ServerConnectionTreeView extends vscode.TreeView {} export type ServerConnectionPanelNode = + | ServerState | ConnectionState | [URL, VariableDefintion]; diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 560b52bb6..714a7144b 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -1,6 +1,42 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` +exports[`getConnectionServerTreeItem > should fall back to the url host when there is no label 1`] = ` +{ + "collapsibleState": 2, + "contextValue": "isDHEServerConnectionParent", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "my-dhe-server:8123", +} +`; + +exports[`getConnectionServerTreeItem > should return a labeled server tree item 1`] = ` +{ + "collapsibleState": 2, + "contextValue": "isDHEServerConnectionParent", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "My DHE Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "Some Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -8,11 +44,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "sync~spin", }, - "label": "localhost:10000", + "label": "Some Worker PQ", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "Some Server", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -20,11 +68,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "sync~spin", }, - "label": "localhost:10000", + "label": "Some Worker PQ", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "vm-connect", + }, + "label": "Some Server", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -32,11 +92,23 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "vm-connect", }, - "label": "localhost:10000", + "label": "Some Worker PQ", +} +`; + +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:false 1`] = ` +{ + "collapsibleState": 2, + "description": "Some Worker PQ", + "iconPath": ThemeIcon { + "color": undefined, + "id": "dh-python", + }, + "label": "Some Server", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:true 1`] = ` { "collapsibleState": 2, "description": undefined, @@ -44,7 +116,7 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "Some Worker PQ", } `; diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 7630e67e7..caff74f23 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { + getConnectionServerTreeItem, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -47,9 +48,9 @@ describe('getPanelConnectionTreeItem', () => { const serverUrl = new URL('http://localhost:10000'); - it.each(matrix(boolValues, boolValues))( - 'should return panel connection tree item: isConnected:%s, isInitialized:%s', - async (isConnected, isInitialized) => { + it.each(matrix(boolValues, boolValues, boolValues))( + 'should return panel connection tree item: isConnected:%s, isInitialized:%s, isWorkerChild:%s', + async (isConnected, isInitialized, isWorkerChild) => { const connection = { isConnected, isInitialized, @@ -59,15 +60,48 @@ describe('getPanelConnectionTreeItem', () => { vi.mocked(isInstanceOf).mockReturnValue(true); - const actual = await getPanelConnectionTreeItem(connection, async () => { - const [consoleType] = await getConsoleTypes(); - return isInitialized ? consoleType : undefined; - }); + const actual = await getPanelConnectionTreeItem( + connection, + async () => { + const [consoleType] = await getConsoleTypes(); + return isInitialized ? consoleType : undefined; + }, + 'Some Server', + 'Some Worker PQ', + isWorkerChild + ); expect(actual).toMatchSnapshot(); } ); }); +describe('getConnectionServerTreeItem', () => { + it('should return a labeled server tree item', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + label: 'My DHE Server', + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); + + it('should fall back to the url host when there is no label', () => { + const server: ServerState = { + type: 'DHE', + url: new URL('https://my-dhe-server:8123'), + isConnected: true, + isRunning: true, + connectionCount: 2, + }; + + expect(getConnectionServerTreeItem(server)).toMatchSnapshot(); + }); +}); + describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index c60ba216d..72fba9d23 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { ConnectionState, ConsoleType, + IServerManager, NonEmptyArray, ServerGroupState, ServerState, @@ -9,6 +10,7 @@ import type { VariableType, } from '../types'; import { + CONNECTION_TREE_ITEM_CONTEXT, DH_PROTECTED_VARIABLE_NAMES, ICON_ID, OPEN_VARIABLE_PANELS_CMD, @@ -79,16 +81,25 @@ export async function getPanelConnectionTreeItem( connection: ConnectionState ) => Promise, serverLabel?: string, - pqName?: string + pqName?: string, + isWorkerChild = false ): Promise { // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); // Prefer the persistent query name (what the DHE Query Monitor shows) over // the local correlation tagId. Falls back to tagId for plain DHC connections. - const description = pqName ?? connection.tagId; + const workerName = pqName ?? connection.tagId; - const label = serverLabel ?? connection.serverUrl.host; + // DHE worker nodes are nested under their server node, so the worker name + // becomes the node label and the server label lives on the parent. Flat DHC + // connections keep the server label as the node label and show the worker + // name as the description. + const label = isWorkerChild + ? workerName + : (serverLabel ?? connection.serverUrl.host); + + const description = isWorkerChild ? undefined : workerName; return { label, @@ -129,6 +140,71 @@ export function getPanelVariableTreeItem([url, variable]: [ }; } +/** + * Type guard for a (DHE) server node within the connection / panel tree root. + * `ServerState` carries `url`; `ConnectionState` carries `serverUrl`. + * @param node A server or connection root node. + */ +export function isServerStateNode( + node: ServerState | ConnectionState +): node is ServerState { + return 'url' in node; +} + +/** + * Compute the root nodes for the connection / panel tree views. DHE worker + * connections are grouped under their parent DHE server node; DHC connections + * remain flat top-level nodes. Roots are sorted by their displayed label. + * @param serverManager Server manager. + * @returns The root nodes (DHE servers and flat DHC connections). + */ +export function getConnectionTreeRootNodes( + serverManager: IServerManager +): (ServerState | ConnectionState)[] { + const dheServers = new Map(); + const flatConnections: ConnectionState[] = []; + + for (const connection of serverManager.getConnections()) { + const server = serverManager.getServerForConnection(connection); + if (server == null) { + flatConnections.push(connection); + } else { + dheServers.set(server.url.toString(), server); + } + } + + return [...flatConnections, ...dheServers.values()].sort((a, b) => + getConnectionNodeSortKey(a).localeCompare(getConnectionNodeSortKey(b)) + ); +} + +function getConnectionNodeSortKey( + node: ServerState | ConnectionState +): string { + return isServerStateNode(node) + ? (node.label ?? node.url.host) + : node.serverUrl.host; +} + +/** + * Get `TreeItem` for a DHE server node in the connection / panel tree views. + * This is a grouping container whose children are the server's worker + * connections. + * @param server DHE server state + */ +export function getConnectionServerTreeItem( + server: ServerState +): vscode.TreeItem { + return { + label: server.label ?? server.url.host, + // The "computer" icon (`vm-connect`) previously used for worker connection + // nodes, before the language (Python/Groovy) icons took their place. + iconPath: new vscode.ThemeIcon(ICON_ID.connected), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + contextValue: CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent, + }; +} + /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected From 31736302155d9346b31c8689364f915f50fa9b34 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:19:03 -0500 Subject: [PATCH 05/68] Removed unused let vars (#DH-22593) --- src/services/ServerManager.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index b5da5942a..44647341e 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -255,10 +255,6 @@ export class ServerManager implements IServerManager { logger.debug('Connecting to server:', serverUrl.href); - let tagId: UniqueID | undefined; - - let placeholderUrl: URL | undefined; - if (serverState.type === 'DHE') { const dheServerUrl = serverUrl; const isNewDheService = !this._dheServiceCache.has(dheServerUrl); From db0754fb16cd040f08e11cec3b5cd599fd18f4d9 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 12:25:45 -0500 Subject: [PATCH 06/68] Ran prettier (#DH-22593) --- src/providers/ServerConnectionPanelTreeProvider.ts | 3 ++- src/providers/ServerConnectionTreeProvider.ts | 3 ++- src/util/treeViewUtils.ts | 4 +--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 603137ffe..06c2cc901 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -50,7 +50,8 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase Date: Tue, 16 Jun 2026 12:36:45 -0500 Subject: [PATCH 07/68] Added format script (#DH-22593) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index deec151a8..f87366fb6 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "docs:start": "./scripts/startDocs", "docs:format": "./scripts/formatDocs", "docs:validate": "./scripts/validateDocs", + "format": "prettier --write \"src/**/*.{ts,js}\"", "icon:gen:dh": "node icons/generate.mjs dh", "icon:gen:ext": "node icons/generate.mjs dh-ext icons/src", "package:dev": "./scripts/package-dev.sh", From f049c2eb40127ac24b37b0ebd28403ea0f42dd8b Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 16 Jun 2026 16:13:28 -0500 Subject: [PATCH 08/68] Uniform nesting of workers grouped by servers (#DH-22593) --- .../ServerConnectionPanelTreeProvider.ts | 29 +++---- src/providers/ServerConnectionTreeProvider.ts | 44 ++++------- src/services/ServerManager.ts | 18 ++--- src/types/serviceTypes.d.ts | 6 +- .../__snapshots__/treeViewUtils.spec.ts.snap | 68 ++-------------- src/util/treeViewUtils.spec.ts | 58 ++++++++++++-- src/util/treeViewUtils.ts | 79 ++++++++++--------- 7 files changed, 141 insertions(+), 161 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 06c2cc901..2ded30729 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -8,13 +8,13 @@ import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, + getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, isServerStateNode, sortByStringProp, } from '../util'; import { getFirstSupportedConsoleType } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { constructor(serverManager: IServerManager, panelService: IPanelService) { @@ -41,31 +41,28 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase => { - // Root: DHE server nodes + flat DHC connection nodes. + // Root: one server node per server that has connections. if (elementOrRoot == null) { return getConnectionTreeRootNodes(this.serverManager); } @@ -75,7 +72,7 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase its worker connections. + // Server node -> its worker connections. if (isServerStateNode(elementOrRoot)) { return this.serverManager .getConnections(elementOrRoot.url) diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index ec2144f1c..941bb1c27 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -5,13 +5,13 @@ import type { ConsoleType, ServerConnectionNode } from '../types'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, + getConnectionWorkerLabel, getConsoleTypeIconId, isInstanceOf, isServerStateNode, sortByStringProp, } from '../util'; import { DhcService } from '../services'; -import { getServerMatchPortIfLocalHost } from '../mcp/utils'; /** * Provider for the server connection tree view. @@ -45,39 +45,23 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase => { - // Root: DHE server nodes + flat DHC connection nodes. + // Root: one server node per server that has connections. if (elementOrRoot == null) { return getConnectionTreeRootNodes(this.serverManager); } @@ -107,7 +91,7 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase its worker connections. + // Server node -> its worker connections. if (isServerStateNode(elementOrRoot)) { return this.serverManager .getConnections(elementOrRoot.url) @@ -132,7 +116,7 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase its parent DHE server (or null for flat DHC). + // Connection node -> its parent server. return this.serverManager.getServerForConnection(element) ?? null; }; } diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 44647341e..8fa150f56 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -733,20 +733,20 @@ export class ServerManager implements IServerManager { }; /** - * Get the parent DHE server for a connection. Returns the DHE server state - * when the connection is a DHE worker (its `serverUrl` is mapped to a DHE - * server URL), or `undefined` for DHC connections, which have no parent - * server node in the tree. + * Get the parent server for a connection. For a DHE worker, resolves the DHE + * server via the worker→server map; for a DHC connection, the connection's + * `serverUrl` is itself the server URL. Returns `undefined` only when no + * matching server is registered. * @param connection The connection to get the parent server for. - * @returns The parent DHE server state, or `undefined`. + * @returns The parent server state, or `undefined`. */ getServerForConnection = ( connection: ConnectionState ): ServerState | undefined => { - const dheServerUrl = this._workerURLToServerURLMap.get( - connection.serverUrl - ); - return dheServerUrl == null ? undefined : this.getServer(dheServerUrl); + const serverUrl = + this._workerURLToServerURLMap.get(connection.serverUrl) ?? + connection.serverUrl; + return this.getServer(serverUrl); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 25080dd9d..4cf63a2c7 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -191,9 +191,9 @@ export interface IServerManager extends IDisposable { getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; /** - * Get the parent DHE server for a connection. Returns the DHE `ServerState` - * when the connection is a DHE worker, or `undefined` for DHC connections - * (which have no parent server node in the tree). + * Get the parent server for a connection. Resolves the DHE server for a DHE + * worker, or the DHC server for a plain DHC connection. Returns `undefined` + * only when no matching server is registered. */ getServerForConnection: ( connection: ConnectionState diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 714a7144b..9733b1ba4 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -24,99 +24,47 @@ exports[`getConnectionServerTreeItem > should return a labeled server tree item } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false 1`] = ` { "collapsibleState": 2, - "description": "Some Worker PQ", "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", }, - "label": "Some Server", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:false, isWorkerChild:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "sync~spin", }, - "label": "Some Worker PQ", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:false 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false 1`] = ` { "collapsibleState": 2, - "description": "Some Worker PQ", - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "Some Server", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:false, isInitialized:true, isWorkerChild:true 1`] = ` -{ - "collapsibleState": 2, - "description": undefined, - "iconPath": ThemeIcon { - "color": undefined, - "id": "sync~spin", - }, - "label": "Some Worker PQ", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:false 1`] = ` -{ - "collapsibleState": 2, - "description": "Some Worker PQ", "iconPath": ThemeIcon { "color": undefined, "id": "vm-connect", }, - "label": "Some Server", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:false, isWorkerChild:true 1`] = ` -{ - "collapsibleState": 2, - "description": undefined, - "iconPath": ThemeIcon { - "color": undefined, - "id": "vm-connect", - }, - "label": "Some Worker PQ", -} -`; - -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:false 1`] = ` -{ - "collapsibleState": 2, - "description": "Some Worker PQ", - "iconPath": ThemeIcon { - "color": undefined, - "id": "dh-python", - }, - "label": "Some Server", + "label": "Some Worker Label", } `; -exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true, isWorkerChild:true 1`] = ` +exports[`getPanelConnectionTreeItem > should return panel connection tree item: isConnected:true, isInitialized:true 1`] = ` { "collapsibleState": 2, - "description": undefined, "iconPath": ThemeIcon { "color": undefined, "id": "dh-python", }, - "label": "Some Worker PQ", + "label": "Some Worker Label", } `; diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index caff74f23..47040d9ce 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { getConnectionServerTreeItem, + getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -14,10 +15,12 @@ import { groupServers, } from './treeViewUtils'; import type { + ConnectionState, ConsoleType, IDhcService, Psk, ServerState, + UniqueID, VariableDefintion, VariableType, } from '../types'; @@ -48,9 +51,9 @@ describe('getPanelConnectionTreeItem', () => { const serverUrl = new URL('http://localhost:10000'); - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return panel connection tree item: isConnected:%s, isInitialized:%s, isWorkerChild:%s', - async (isConnected, isInitialized, isWorkerChild) => { + it.each(matrix(boolValues, boolValues))( + 'should return panel connection tree item: isConnected:%s, isInitialized:%s', + async (isConnected, isInitialized) => { const connection = { isConnected, isInitialized, @@ -66,9 +69,7 @@ describe('getPanelConnectionTreeItem', () => { const [consoleType] = await getConsoleTypes(); return isInitialized ? consoleType : undefined; }, - 'Some Server', - 'Some Worker PQ', - isWorkerChild + 'Some Worker Label' ); expect(actual).toMatchSnapshot(); } @@ -102,6 +103,51 @@ describe('getConnectionServerTreeItem', () => { }); }); +describe('getConnectionWorkerLabel', () => { + const connection = { + isConnected: true, + serverUrl: new URL('http://localhost:10000'), + tagId: 'mock.tagId' as UniqueID, + } as ConnectionState; + + const dhcServer: ServerState = { + type: 'DHC', + url: new URL('http://localhost:10000'), + label: 'My DHC Server', + isConnected: true, + isRunning: true, + connectionCount: 1, + }; + + it('should prefer the persistent-query name when present (DHE worker)', () => { + expect( + getConnectionWorkerLabel(dhcServer, connection, 'Some Worker PQ') + ).toBe('Some Worker PQ'); + }); + + it('should fall back to the server label when there is no pq name (DHC)', () => { + expect(getConnectionWorkerLabel(dhcServer, connection, undefined)).toBe( + 'My DHC Server' + ); + }); + + it('should fall back to the server url host when the server has no label', () => { + expect( + getConnectionWorkerLabel( + { ...dhcServer, label: undefined }, + connection, + undefined + ) + ).toBe('localhost:10000'); + }); + + it('should fall back to the connection serverUrl host when no parent server resolves', () => { + expect(getConnectionWorkerLabel(undefined, connection, undefined)).toBe( + 'localhost:10000' + ); + }); +}); + describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index fb8b85205..d0f9548bd 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -80,30 +80,13 @@ export async function getPanelConnectionTreeItem( getConsoleType: ( connection: ConnectionState ) => Promise, - serverLabel?: string, - pqName?: string, - isWorkerChild = false + label: string ): Promise { // Console type (language) drives the node icon rather than the description. const consoleType = await getConsoleType(connection); - // Prefer the persistent query name (what the DHE Query Monitor shows) over - // the local correlation tagId. Falls back to tagId for plain DHC connections. - const workerName = pqName ?? connection.tagId; - - // DHE worker nodes are nested under their server node, so the worker name - // becomes the node label and the server label lives on the parent. Flat DHC - // connections keep the server label as the node label and show the worker - // name as the description. - const label = isWorkerChild - ? workerName - : (serverLabel ?? connection.serverUrl.host); - - const description = isWorkerChild ? undefined : workerName; - return { label, - description, collapsibleState: vscode.TreeItemCollapsibleState.Expanded, // Show the language (Python/Groovy) icon when idle/connected; show the // spinner while busy (connecting or running code). @@ -152,38 +135,60 @@ export function isServerStateNode( } /** - * Compute the root nodes for the connection / panel tree views. DHE worker - * connections are grouped under their parent DHE server node; DHC connections - * remain flat top-level nodes. Roots are sorted by their displayed label. + * Get the label shown for a server node in the connection / panel tree views. + * @param server Server state. + */ +export function getConnectionServerLabel(server: ServerState): string { + return server.label ?? server.url.host; +} + +/** + * Get the label shown for a worker (connection) node in the connection / panel + * tree views. DHE workers use their persistent-query name; DHC connections, + * which have none, fall back to their server's label so the single child + * mirrors its parent server node. + * @param parentServer The connection's parent server, if resolved. + * @param connection The connection. + * @param pqName The persistent-query name (DHE workers only). + */ +export function getConnectionWorkerLabel( + parentServer: ServerState | undefined, + connection: ConnectionState, + pqName: string | undefined +): string { + const serverLabel = + parentServer == null + ? connection.serverUrl.host + : getConnectionServerLabel(parentServer); + + return pqName ?? serverLabel ?? connection.tagId ?? ''; +} + +/** + * Compute the root nodes for the connection / panel tree views. Every + * connection is grouped under its parent server node (DHC and DHE alike), so a + * community server with a single worker has the same hierarchy shape as an + * enterprise server with many. Roots are sorted by their displayed label. * @param serverManager Server manager. - * @returns The root nodes (DHE servers and flat DHC connections). + * @returns The server root nodes (only servers that have connections). */ export function getConnectionTreeRootNodes( serverManager: IServerManager -): (ServerState | ConnectionState)[] { - const dheServers = new Map(); - const flatConnections: ConnectionState[] = []; +): ServerState[] { + const servers = new Map(); for (const connection of serverManager.getConnections()) { const server = serverManager.getServerForConnection(connection); - if (server == null) { - flatConnections.push(connection); - } else { - dheServers.set(server.url.toString(), server); + if (server != null) { + servers.set(server.url.toString(), server); } } - return [...flatConnections, ...dheServers.values()].sort((a, b) => - getConnectionNodeSortKey(a).localeCompare(getConnectionNodeSortKey(b)) + return [...servers.values()].sort((a, b) => + getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) ); } -function getConnectionNodeSortKey(node: ServerState | ConnectionState): string { - return isServerStateNode(node) - ? (node.label ?? node.url.host) - : node.serverUrl.host; -} - /** * Get `TreeItem` for a DHE server node in the connection / panel tree views. * This is a grouping container whose children are the server's worker From 56fd4353dcc59f9423ab39d459e58ce130aadcdf Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 10:22:45 -0500 Subject: [PATCH 09/68] server connecting status (#DH-22593) --- src/common/constants.ts | 1 + src/providers/ServerTreeProvider.ts | 5 +- src/services/ServerManager.spec.ts | 141 ++++++-- src/services/ServerManager.ts | 305 +++++++++-------- src/types/serviceTypes.d.ts | 3 + .../__snapshots__/treeViewUtils.spec.ts.snap | 320 ++++++++++++++++-- src/util/treeViewUtils.spec.ts | 49 +-- src/util/treeViewUtils.ts | 29 +- 8 files changed, 625 insertions(+), 228 deletions(-) diff --git a/src/common/constants.ts b/src/common/constants.ts index b7112fec8..827be37dc 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -172,6 +172,7 @@ export const SERVER_TREE_ITEM_CONTEXT = { isManagedServerConnected: 'isManagedServerConnected', isManagedServerConnecting: 'isManagedServerConnecting', isManagedServerDisconnected: 'isManagedServerDisconnected', + isServerConnecting: 'isServerConnecting', isServerRunningConnected: 'isServerRunningConnected', isServerRunningDisconnected: 'isServerRunningDisconnected', isServerStopped: 'isServerStopped', diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index 629382f53..22d6070b8 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -21,7 +21,10 @@ export class ServerTreeProvider extends ServerTreeProviderBase { return getServerGroupTreeItem(element, this.serverManager.canStartServer); } - return getServerTreeItem(element); + return getServerTreeItem( + element, + this.serverManager.isConnecting(element.url) + ); }; getChildren(elementOrRoot?: ServerNode): vscode.ProviderResult { diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 8ae55c9a4..46cc573b8 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -37,7 +37,16 @@ type TestServerManager = PublicOf & { _serverMap: URLMap; _connectionMap: URLMap; _pendingConnectionMap: URLMap>; - _doConnectToServer: ReturnType; + _dhcServiceFactory: { create: ReturnType }; + _dheServiceCache: { + has: ReturnType; + get: ReturnType; + }; + _setPendingConnection: ( + serverUrl: URL, + connectionPromise: Promise + ) => void; + _clearPendingConnection: (serverUrl: URL) => void; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -107,21 +116,13 @@ const dheServer0 = mockServerState({ type: 'DHE', }); const cn1 = mockConnectionState(serverUrl); -const cn2 = mockConnectionState(serverUrl); describe('ServerManager.connectToServer', () => { let manager: TestServerManager; - let promise: Promise; - let resolve: (value: ConnectionState | null) => void; - beforeEach(() => { vi.clearAllMocks(); manager = createServerManager(); - - ({ promise, resolve } = withResolvers()); - - manager._doConnectToServer = vi.fn().mockReturnValue(promise); }); it('throws when the server is not found', async () => { @@ -137,50 +138,122 @@ describe('ServerManager.connectToServer', () => { const result = await manager.connectToServer(serverUrl); expect(result).toBe(cn1); - expect(manager._doConnectToServer).not.toHaveBeenCalled(); + expect(manager._dhcServiceFactory.create).not.toHaveBeenCalled(); }); it('only connects once when called concurrently for the same DHC server', async () => { manager._serverMap.set(dhcServer0.url, dhcServer0); + // Hold the client handshake open so the connection stays in flight. + const { promise: clientPromise, resolve: resolveClient } = + withResolvers(); + const connection = { + getClient: vi.fn().mockReturnValue(clientPromise), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create.mockReturnValue(connection); + const first = manager.connectToServer(serverUrl); const second = manager.connectToServer(serverUrl); - // The in-progress connection is reused rather than starting a new one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(1); + // The second call dedupes against the in-flight connection rather than + // creating a new one. + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(1); + expect(manager.isConnecting(serverUrl)).toBe(true); - resolve(cn1); + resolveClient({}); - expect(await first).toBe(cn1); - expect(await second).toBe(cn1); + expect(await first).toBe(connection); + expect(await second).toBe(connection); }); - it('clears the pending connection once it resolves so later calls reconnect', async () => { - manager._serverMap.set(dhcServer0.url, dhcServer0); + it('dedups concurrent client connections for DHE servers', () => { + manager._serverMap.set(dheServer0.url, dheServer0); - manager._doConnectToServer.mockResolvedValue(cn1); - const first = manager.connectToServer(serverUrl); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(true); - expect(await first).toBe(cn1); + // Leave the DHE service acquisition pending so the client connection stays + // in flight. + const { promise } = withResolvers(); + manager._dheServiceCache.get.mockReturnValue(promise); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + void manager.connectToServer(serverUrl); + void manager.connectToServer(serverUrl); - // A subsequent connect attempt is not blocked by the resolved pending entry. - manager._doConnectToServer.mockResolvedValue(cn2); - const second = manager.connectToServer(serverUrl); - expect(await second).toBe(cn2); + // The DHE client connection is singular, so concurrent attempts reuse the + // in-flight connection rather than starting a second one (multiple workers + // are created later, off the single client connection). + expect(manager._dheServiceCache.get).toHaveBeenCalledTimes(1); + expect(manager.isConnecting(serverUrl)).toBe(true); + }); +}); + +describe('ServerManager.isConnecting', () => { + let manager: TestServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createServerManager(); + }); - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); + it('toggles isConnecting and fires onDidUpdate on set/clear', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); + + expect(manager.isConnecting(serverUrl)).toBe(false); + + manager._setPendingConnection(serverUrl, Promise.resolve(null)); + expect(manager.isConnecting(serverUrl)).toBe(true); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + + manager._clearPendingConnection(serverUrl); + expect(manager.isConnecting(serverUrl)).toBe(false); + expect(onDidUpdate).toHaveBeenCalledTimes(2); }); - it('does not track pending connections for DHE servers', async () => { - manager._serverMap.set(dheServer0.url, dheServer0); + it('clears idempotently and does not fire when there is no pending entry', () => { + const onDidUpdate = vi.fn(); + manager.onDidUpdate(onDidUpdate); - void manager.connectToServer(serverUrl); - void manager.connectToServer(serverUrl); + // Clearing when not connecting is a no-op (no fire). + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).not.toHaveBeenCalled(); + + manager._setPendingConnection(serverUrl, Promise.resolve(null)); + expect(onDidUpdate).toHaveBeenCalledTimes(1); + + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(2); + + // Clearing again is a no-op (no extra fire) — covers DHE's early clear + // followed by the connect-settle clear. + manager._clearPendingConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(2); + }); + + it('clears the pending entry when a connect settles, so a retry is not blocked', async () => { + manager._serverMap.set(dhcServer0.url, dhcServer0); - // DHE supports multiple connections, so concurrent calls each start one. - expect(manager._doConnectToServer).toHaveBeenCalledTimes(2); - expect(manager._pendingConnectionMap.has(serverUrl)).toBe(false); + // First attempt fails to get a client; `_doConnectToServer`'s `finally` + // must clear the pending entry (no stale entry left behind). + const failConnection = { getClient: vi.fn().mockResolvedValue(null) }; + const okConnection = { + getClient: vi.fn().mockResolvedValue({}), + initSession: vi.fn().mockResolvedValue(true), + onDidDisconnect: vi.fn(), + onDidChangeRunningCodeStatus: vi.fn(), + }; + manager._dhcServiceFactory.create + .mockReturnValueOnce(failConnection) + .mockReturnValueOnce(okConnection); + + expect(await manager.connectToServer(serverUrl)).toBeNull(); + expect(manager.isConnecting(serverUrl)).toBe(false); + + // The retry is not blocked by a stale pending entry — it starts a new + // connection rather than deduping against the failed one. + expect(await manager.connectToServer(serverUrl)).toBe(okConnection); + expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(2); + expect(manager.isConnecting(serverUrl)).toBe(false); }); }); diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 8fa150f56..0dfead421 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -33,6 +33,7 @@ import { uniqueId, URIMap, URLMap, + withResolvers, } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; @@ -108,6 +109,23 @@ export class ServerManager implements IServerManager { private readonly _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; + private _setPendingConnection = ( + serverUrl: URL, + connectionPromise: Promise + ): void => { + this._pendingConnectionMap.set(serverUrl, connectionPromise); + this._onDidUpdate.fire(); + }; + + private _clearPendingConnection = (serverUrl: URL): void => { + if (this._pendingConnectionMap.delete(serverUrl)) { + this._onDidUpdate.fire(); + } + }; + + isConnecting = (serverUrl: URL): boolean => + this._pendingConnectionMap.has(serverUrl); + private _lastServerRunningStatus = new URLMap(); private _hasEverUpdatedStatus = false; @@ -223,27 +241,11 @@ export class ServerManager implements IServerManager { return this._pendingConnectionMap.getOrThrow(serverUrl); } - const connectionPromise = this._doConnectToServer( + return this._doConnectToServer( serverState, workerConsoleType, operateAsAnotherUser ); - - // We only support 1 connection for DHC servers in the extension, but the - // count doesn't get updated until the connection is established, so we need - // to mark pending connections to prevent multiple simultaneous connection - // attempts to the same DHC server. - if (serverState.type === 'DHC') { - this._pendingConnectionMap.set( - serverUrl, - connectionPromise.then(result => { - this._pendingConnectionMap.delete(serverUrl); - return result; - }) - ); - } - - return connectionPromise; }; private _doConnectToServer = async ( @@ -251,156 +253,183 @@ export class ServerManager implements IServerManager { workerConsoleType?: ConsoleType, operateAsAnotherUser: boolean = false ): Promise => { - let serverUrl = serverState.url; + const serverUrl = serverState.url; logger.debug('Connecting to server:', serverUrl.href); - if (serverState.type === 'DHE') { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); + // Register the in-flight connection synchronously — before the first + // await — so concurrent callers dedupe against it. The deferred is settled + // with the result once the connect completes (see `finally`). + const { promise, resolve } = withResolvers(); + this._setPendingConnection(serverUrl, promise); + + let result: ConnectionState | null = null; + try { + if (serverState.type === 'DHE') { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); + this._onDidUpdate.fire(); + } - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } + // Client is live; the DHE connecting window ends here. The worker + // attach/create phase below is intentionally not covered (the `finally` + // clear is then an idempotent no-op). + this._clearPendingConnection(dheServerUrl); + + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onDidWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onDidWorkerRemoved(serial => + this._reconcileDetach(serial) + ); + } - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onDidWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) + // Snapshot currently-running attachable workers and compute the + // not-yet-attached subset. + const attachable = await dheService.listAttachableWorkers(); + const toAttach = attachable.filter( + qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) ); - dheService.onDidWorkerRemoved(serial => this._reconcileDetach(serial)); - } - - // Snapshot currently-running attachable workers and compute the - // not-yet-attached subset. - const attachable = await dheService.listAttachableWorkers(); - const toAttach = attachable.filter( - qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) - ); - if (toAttach.length > 0) { - // Attach existing workers in batches to avoid server stampede. - const BATCH_SIZE = 4; - let firstConnection: ConnectionState | null = null; - for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { - const batch = toAttach.slice(i, i + BATCH_SIZE); - const connections = await Promise.all( - batch.map(async qi => { - const workerInfo = dheService.attachWorker(qi); - return this._connectWorker(dheServerUrl, workerInfo); - }) - ); - if (firstConnection == null) { - firstConnection = connections.find(c => c != null) ?? null; + if (toAttach.length > 0) { + // Attach existing workers in batches to avoid server stampede. + const BATCH_SIZE = 4; + let firstConnection: ConnectionState | null = null; + for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { + const batch = toAttach.slice(i, i + BATCH_SIZE); + const connections = await Promise.all( + batch.map(async qi => { + const workerInfo = dheService.attachWorker(qi); + return this._connectWorker(dheServerUrl, workerInfo); + }) + ); + if (firstConnection == null) { + firstConnection = connections.find(c => c != null) ?? null; + } } + if (toAttach.length > 1) { + this._toaster.info(`Attached to ${toAttach.length} worker(s).`); + } + result = firstConnection; + return result; } - if (toAttach.length > 1) { - this._toaster.info(`Attached to ${toAttach.length} worker(s).`); - } - return firstConnection; - } - // Nothing left to attach — either no workers exist for this user, or all - // of them already have live connections. Create a fresh one so clicking a - // server always yields a usable session. - const tagId = uniqueId(); - const placeholderUrl = this.addWorkerPlaceholderConnection( - dheServerUrl, - tagId - ); + // Nothing left to attach — either no workers exist for this user, or all + // of them already have live connections. Create a fresh one so clicking a + // server always yields a usable session. + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheServerUrl, + tagId + ); - let workerInfo: WorkerInfo; - try { - workerInfo = await dheService.createWorker(tagId, workerConsoleType); + let workerInfo: WorkerInfo; + try { + workerInfo = await dheService.createWorker(tagId, workerConsoleType); - // If the worker finished creating but there is no placeholder - // connection, the user cancelled before it was ready. - if (!this._connectionMap.has(placeholderUrl)) { - dheService.deleteWorker(workerInfo.workerUrl); - this._onDidUpdate.fire(); + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. + if (!this._connectionMap.has(placeholderUrl)) { + dheService.deleteWorker(workerInfo.workerUrl); + this._onDidUpdate.fire(); + return null; + } + } catch (err) { + if (err instanceof QueryCreationCancelledError) { + logger.info(err); + const msg = 'Connection cancelled.'; + this._outputChannel.appendLine(msg); + this._toaster.info(msg); + } else { + const msg = + err instanceof QueryStartupFailureError + ? err.message + : 'Failed to create worker.'; + logger.error(err); + this._outputChannel.appendLine(msg); + this._toaster.error(msg); + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); return null; } - } catch (err) { - if (err instanceof QueryCreationCancelledError) { - logger.info(err); - const msg = 'Connection cancelled.'; - this._outputChannel.appendLine(msg); - this._toaster.info(msg); - } else { - const msg = - err instanceof QueryStartupFailureError - ? err.message - : 'Failed to create worker.'; - logger.error(err); - this._outputChannel.appendLine(msg); - this._toaster.error(msg); - } this.removeWorkerPlaceholderConnection(placeholderUrl); - return null; + result = await this._connectWorker(dheServerUrl, workerInfo); + return result; } - this.removeWorkerPlaceholderConnection(placeholderUrl); - return this._connectWorker(dheServerUrl, workerInfo); - } - - // DHC path: single direct connection. - const connection = this._dhcServiceFactory.create(serverUrl, undefined); + // DHC path: single direct connection. + const connection = this._dhcServiceFactory.create(serverUrl, undefined); - // Initialize client + prompt for login if necessary - const coreClient = await connection.getClient(); + // Initialize client + prompt for login if necessary + const coreClient = await connection.getClient(); - if (coreClient == null) { - return null; - } + if (coreClient == null) { + return null; + } - this._connectionMap.set(serverUrl, connection); - this._onDidUpdate.fire(); + this._connectionMap.set(serverUrl, connection); + this._onDidUpdate.fire(); - if (!(await connection.initSession())) { - this._coreClientCache.delete(serverUrl); + if (!(await connection.initSession())) { + this._coreClientCache.delete(serverUrl); - connection.dispose(); - this._connectionMap.delete(serverUrl); - return null; - } + connection.dispose(); + this._connectionMap.delete(serverUrl); + return null; + } - connection.onDidDisconnect(() => { - logger.debug('onDidDisconnect fired for:', serverUrl.href); - this.disconnectFromServer(serverUrl); - }); + connection.onDidDisconnect(() => { + logger.debug('onDidDisconnect fired for:', serverUrl.href); + this.disconnectFromServer(serverUrl); + }); - connection.onDidChangeRunningCodeStatus?.(() => { - this._onDidUpdate.fire(); - }); + connection.onDidChangeRunningCodeStatus?.(() => { + this._onDidUpdate.fire(); + }); - this.updateConnectionCount(serverUrl, 1); + this.updateConnectionCount(serverUrl, 1); - this._onDidConnect.fire(serverUrl); - this._onDidUpdate.fire(); + this._onDidConnect.fire(serverUrl); + this._onDidUpdate.fire(); - return this._connectionMap.get(serverUrl) ?? null; + result = this._connectionMap.get(serverUrl) ?? null; + return result; + } finally { + // Settle the deferred for any deduped caller, and clear the pending + // entry. The clear ends the connecting window — DHC: the whole connect; + // DHE: idempotent here, since the explicit clear above already ran once + // the client was established (this still covers DHE failures before + // establishment). + resolve(result); + this._clearPendingConnection(serverUrl); + } }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 4cf63a2c7..1566234f9 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -187,6 +187,9 @@ export interface IServerManager extends IDisposable { hasConnectionUris: (connection: ConnectionState) => boolean; + /** Whether a client connection to the given server is currently being established. */ + isConnecting: (serverUrl: URL) => boolean; + getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; getConnectionUris: (connection: ConnectionState) => vscode.Uri[]; diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 9733b1ba4..41d4b4fb1 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -332,21 +332,37 @@ exports[`getPanelVariableTreeItem > should return panel variable tree item: type } `; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningDisconnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerDisconnected"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; -exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; +exports[`getServerContextValue > should return contextValue based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"isServerStopped"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"isServerRunningConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"isManagedServerConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"isManagedServerConnected"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"isServerConnecting"`; + +exports[`getServerContextValue > should return contextValue based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"isServerConnecting"`; exports[`getServerDescription > should return server description based on parameters: connectionCount=0, isManaged=false, label=some label 1`] = `"some label"`; @@ -420,23 +436,39 @@ exports[`getServerGroupTreeItem > should return server group tree item: group=Ru } `; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=false, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=false, isManaged=true, isRunning=true 1`] = `"circle-large-outline"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=false 1`] = `"circle-slash"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = `"circle-slash"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = `"sync~spin"`; -exports[`getServerIconID > should return icon id based on server state: isConnected=true, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = `"circle-large-filled"`; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = `"sync~spin"`; + +exports[`getServerIconID > should return icon id based on server state: isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = `"sync~spin"`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -450,7 +482,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -477,7 +509,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -491,7 +523,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -519,7 +551,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -533,7 +621,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isServerRunningConnected", @@ -547,7 +635,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -561,7 +649,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -575,7 +663,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHC, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHC, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -589,7 +733,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -616,7 +760,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnecting", @@ -630,7 +774,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -658,7 +802,63 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=false, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isServerStopped", @@ -672,7 +872,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=false, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=false, isRunning=true 1`] = ` { "command": { "arguments": [ @@ -699,7 +899,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=false 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=false 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -713,7 +913,7 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; -exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isManaged=true, isRunning=true 1`] = ` +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=false, isManaged=true, isRunning=true 1`] = ` { "command": undefined, "contextValue": "isManagedServerConnected", @@ -727,6 +927,62 @@ exports[`getServerTreeItem > should return server tree item: type=DHE, isConnect } `; +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=false, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "(1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=false 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + +exports[`getServerTreeItem > should return server tree item: type=DHE, isConnected=true, isConnecting=true, isManaged=true, isRunning=true 1`] = ` +{ + "command": undefined, + "contextValue": "isServerConnecting", + "description": "pip (1)", + "iconPath": ThemeIcon { + "color": undefined, + "id": "sync~spin", + }, + "label": "localhost:10000", + "tooltip": "Connecting to localhost:10000…", +} +`; + exports[`getVariableIconPath > should return icon path for variableType 1`] = ` [ [ diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 47040d9ce..6552fa8b0 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -166,11 +166,12 @@ describe('getPanelVariableTreeItem', () => { }); describe('getServerContextValue', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return contextValue based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return contextValue based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { const actual = getServerContextValue({ isConnected, + isConnecting, isDHE: false, isManaged, isRunning, @@ -217,10 +218,15 @@ describe('getServerGroupTreeItem', () => { }); describe('getServerIconID', () => { - it.each(matrix(boolValues, boolValues, boolValues))( - 'should return icon id based on server state: isConnected=%s, isManaged=%s, isRunning=%s', - (isConnected, isManaged, isRunning) => { - const actual = getServerIconID({ isConnected, isManaged, isRunning }); + it.each(matrix(boolValues, boolValues, boolValues, boolValues))( + 'should return icon id based on server state: isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerIconID({ + isConnected, + isConnecting, + isManaged, + isRunning, + }); expect(actual).toMatchSnapshot(); } ); @@ -237,19 +243,22 @@ describe('getServerTreeItem', () => { connectionCount: 0, }; - it.each(matrix(typeValues, boolValues, boolValues, boolValues))( - 'should return server tree item: type=%s, isConnected=%s, isManaged=%s, isRunning=%s', - (type, isConnected, isManaged, isRunning) => { - const actual = getServerTreeItem({ - ...dhcServerState, - ...(isManaged - ? { isManaged: true, psk: 'mock.psk' as Psk } - : { isManaged: false }), - type, - connectionCount: isConnected ? 1 : 0, - isConnected, - isRunning, - }); + it.each(matrix(typeValues, boolValues, boolValues, boolValues, boolValues))( + 'should return server tree item: type=%s, isConnected=%s, isConnecting=%s, isManaged=%s, isRunning=%s', + (type, isConnected, isConnecting, isManaged, isRunning) => { + const actual = getServerTreeItem( + { + ...dhcServerState, + ...(isManaged + ? { isManaged: true, psk: 'mock.psk' as Psk } + : { isManaged: false }), + type, + connectionCount: isConnected ? 1 : 0, + isConnected, + isRunning, + }, + isConnecting + ); expect(actual).toMatchSnapshot(); } diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index d0f9548bd..422c887eb 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -211,21 +211,28 @@ export function getConnectionServerTreeItem( /** * Get `contextValue` for server tree items. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isDHE Whether the server is a DHE server * @param isManaged Whether the server is managed * @param isRunning Whether the server is running */ export function getServerContextValue({ isConnected, + isConnecting, isDHE, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isDHE: boolean; isManaged: boolean; isRunning: boolean; }): ServerTreeItemContextValue { + if (isConnecting) { + return SERVER_TREE_ITEM_CONTEXT.isServerConnecting; + } + if (isManaged) { return isConnected ? SERVER_TREE_ITEM_CONTEXT.isManagedServerConnected @@ -314,19 +321,26 @@ export function getServerGroupTreeItem( /** * Get icon id for a server in the UI. e.g. for tree nodes. * @param isConnected Whether the server is connected + * @param isConnecting Whether a client connection is currently being established * @param isManaged Whether the server is managed * @param isRunning Whether the server is running * @returns Icon id for server tree item */ export function getServerIconID({ isConnected, + isConnecting, isManaged, isRunning, }: { isConnected: boolean; + isConnecting: boolean; isManaged: boolean; isRunning: boolean; }): string { + if (isConnecting) { + return ICON_ID.connecting; + } + return isRunning ? isConnected ? ICON_ID.serverConnected @@ -343,9 +357,13 @@ export function getServerIconID({ * number of connected workers in the case of DHE) * @param isManaged Whether the server is managed * @param isRunning Whether the server is running + * @param isConnecting Whether a client connection is currently being established * @returns Tree item representing the server */ -export function getServerTreeItem(server: ServerState): vscode.TreeItem { +export function getServerTreeItem( + server: ServerState, + isConnecting: boolean +): vscode.TreeItem { const { connectionCount, isConnected, @@ -356,6 +374,7 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { const contextValue = getServerContextValue({ isConnected, + isConnecting, isDHE: type === 'DHE', isManaged, isRunning, @@ -377,10 +396,14 @@ export function getServerTreeItem(server: ServerState): vscode.TreeItem { return { label, description, - tooltip: canConnect ? `Click to connect to ${label}` : label, + tooltip: isConnecting + ? `Connecting to ${label}…` + : canConnect + ? `Click to connect to ${label}` + : label, contextValue, iconPath: new vscode.ThemeIcon( - getServerIconID({ isConnected, isManaged, isRunning }) + getServerIconID({ isConnected, isConnecting, isManaged, isRunning }) ), command: canConnect ? { From 9abde971de6860aae61d5ce4c932f341d6ad9c9a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:05:55 -0500 Subject: [PATCH 10/68] Cleaned up server / connection management (#DH-22593) --- src/controllers/ConnectionController.ts | 4 + src/dh/dhe.ts | 22 +- src/providers/ServerTreeProvider.ts | 2 +- src/services/DheService.ts | 140 ++++----- src/services/ServerManager.ts | 390 +++++++++++++----------- src/types/serviceTypes.d.ts | 12 +- 6 files changed, 305 insertions(+), 265 deletions(-) diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index bef0cfaff..264f51f05 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -185,6 +185,10 @@ export class ConnectionController ); if (cn == null) { + updateConnectionStatusBarItem( + this._connectStatusBarItem, + 'disconnected' + ); return; } diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index dc89450ce..8ee20f487 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -421,7 +421,7 @@ export async function getDheAuthConfig( */ export function isAttachableWorker( queryInfo: QueryInfo, - operateAs: string + operateAs: string | null ): boolean { return ( queryInfo.type === INTERACTIVE_CONSOLE_QUERY_TYPE && @@ -434,26 +434,34 @@ export function isAttachableWorker( * List all running InteractiveConsole workers owned by the current effective * user. * @param dheClient DHE client to use. + * @param exclude Iterable of query serials to exclude from the results. * @returns A promise resolving to the filtered QueryInfo array. */ export async function listAttachableWorkers( - dheClient: DheAuthenticatedClient + dheClient: DheAuthenticatedClient, + exclude: Iterable ): Promise { const userInfo = await dheClient.getUserInfo(); const operateAs = userInfo.operateAs; + const excludeSet = new Set(exclude); + return dheClient .getKnownConfigs() - .filter(qi => isAttachableWorker(qi, operateAs)); + .filter( + qi => + isAttachableWorker(qi, operateAs) && + !excludeSet.has(qi.serial as QuerySerial) + ); } /** - * Build WorkerInfo from an already-Running QueryInfo without any I/O or + * Convert a QueryInfo object to a WorkerInfo object without any I/O or * side effects. Returns undefined when `queryInfo.designated` is null. * @param tagId Unique tag id to associate with the worker. * @param queryInfo The running query info. * @returns WorkerInfo or undefined. */ -export function buildWorkerInfo( +export function getWorkerInfoFromQueryInfo( tagId: UniqueID, queryInfo: QueryInfo ): WorkerInfo | undefined { @@ -511,7 +519,7 @@ export function getSerialFromTagId( * @param querySerial Serial of the query to get worker info for. * @returns A promise that resolves to the worker info when the worker is ready. */ -export async function getWorkerInfoFromQuery( +export async function getWorkerInfoFromQuerySerial( tagId: UniqueID, dhe: DheType, dheClient: DheAuthenticatedClient, @@ -573,7 +581,7 @@ export async function getWorkerInfoFromQuery( } } - return buildWorkerInfo(tagId, queryInfo); + return getWorkerInfoFromQueryInfo(tagId, queryInfo); } /** diff --git a/src/providers/ServerTreeProvider.ts b/src/providers/ServerTreeProvider.ts index 22d6070b8..8eafaaad3 100644 --- a/src/providers/ServerTreeProvider.ts +++ b/src/providers/ServerTreeProvider.ts @@ -23,7 +23,7 @@ export class ServerTreeProvider extends ServerTreeProviderBase { return getServerTreeItem( element, - this.serverManager.isConnecting(element.url) + this.serverManager.isServerConnecting(element.url) ); }; diff --git a/src/services/DheService.ts b/src/services/DheService.ts index e29096617..9cca2fb28 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -20,15 +20,15 @@ import { } from '../types'; import { Logger, uniqueId, URLMap } from '../util'; import { - buildWorkerInfo, + getWorkerInfoFromQueryInfo, createInteractiveConsoleQuery, createQueryName, deleteQueries, getDheFeatures, getSerialFromTagId, - getWorkerInfoFromQuery, + getWorkerInfoFromQuerySerial, isAttachableWorker, - listAttachableWorkers as listAttachableWorkersHelper, + listAttachableWorkers, } from '../dh/dhe'; import { CLOSE_CREATE_QUERY_VIEW_CMD, @@ -106,22 +106,22 @@ export class DheService implements IDheService { private _isConnected: boolean = false; private _operateAs: string | null = null; private _removeConfigListeners: (() => void) | null = null; - private readonly _creatingTagIds = new Set(); + private readonly _config: IConfigService; private readonly _dheClientCache: URLMap; private readonly _dheJsApiCache: IAsyncCacheService; private readonly _dheServerFeaturesCache: URLMap; + private readonly _pendingQueryTagIds = new Set(); private readonly _querySerialSet: Set; private readonly _interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory; private readonly _toaster: IToastService; private readonly _workerInfoMap: URLMap; - private readonly _onDidWorkerAttachable = - new vscode.EventEmitter(); - readonly onDidWorkerAttachable = this._onDidWorkerAttachable.event; + private readonly _onWorkerAttachable = new vscode.EventEmitter(); + readonly onWorkerAttachable = this._onWorkerAttachable.event; - private readonly _onDidWorkerRemoved = new vscode.EventEmitter(); - readonly onDidWorkerRemoved = this._onDidWorkerRemoved.event; + private readonly _onWorkerRemoved = new vscode.EventEmitter(); + readonly onWorkerRemoved = this._onWorkerRemoved.event; readonly serverUrl: URL; @@ -221,7 +221,6 @@ export class DheService implements IDheService { this._removeConfigListeners?.(); const dhe = await this._dheJsApiCache.get(this.serverUrl); - const operateAs = this._operateAs ?? ''; const onConfigAddedOrUpdated = ({ detail: queryInfo, @@ -229,43 +228,32 @@ export class DheService implements IDheService { const status = queryInfo.designated?.status; if (isTerminalQueryStatus(status)) { - // Terminal status on a tracked worker → signal removal. - const isTracked = [...this._workerInfoMap.values()].some( - w => w.serial === queryInfo.serial - ); - if (isTracked) { + // Terminal status on a tracked worker, fire _onWorkerRemoved + if (this._isQueryTracked(queryInfo)) { logger.info('Worker entered terminal state:', queryInfo.serial); - this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); } - } else if ( - isAttachableWorker(queryInfo, operateAs) && - !this._querySerialSet.has(queryInfo.serial as QuerySerial) && - !this._isCreatingQuery(queryInfo.name) + + return; + } + + if ( + !this._isQueryOwned(queryInfo) && + isAttachableWorker(queryInfo, this._operateAs) ) { - // Skip workers the extension is creating or already owns. The create - // path connects those directly with the tagId it allocated for the - // query name (`IC - VS Code - `). Without this guard, the same - // `EVENT_CONFIG_UPDATED` that flips a freshly-created worker to - // `Running` would auto-attach it here, racing the create path: it would - // double-connect with a *new* random tagId and let the now-functioning - // detach/cleanup machinery tear down a worker mid-creation (surfacing as - // a spurious startup failure). `_querySerialSet` covers the worker once - // its serial is known; `_isCreatingQuery` covers the earlier window - // (e.g. the iframe flow) where only the query name is known (Gotcha 4). - this._onDidWorkerAttachable.fire(queryInfo); + this._onWorkerAttachable.fire(queryInfo); } }; const onConfigRemoved = ({ detail: queryInfo, }: CustomEvent): void => { - const isTracked = [...this._workerInfoMap.values()].some( - w => w.serial === queryInfo.serial - ); - if (isTracked) { - logger.info('Worker removed:', queryInfo.serial); - this._onDidWorkerRemoved.fire(queryInfo.serial as QuerySerial); + if (!this._isQueryTracked(queryInfo)) { + return; } + + logger.info('Worker removed:', queryInfo.serial); + this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); }; const removeAdded = dheClient.client.addEventListener( @@ -343,22 +331,32 @@ export class DheService implements IDheService { return dheClient; } - /** - * Whether the given query name belongs to a worker this extension is - * currently creating. Created workers are named `IC - VS Code - ` - * (the iframe create flow may append a `_vN` version suffix), so a prefix - * match against in-flight tagIds covers both forms. Used to keep the - * auto-attach event path off a worker the create path owns end-to-end, during - * the window before its serial is known and added to `_querySerialSet`. - * @param queryName The query name from a config event. - */ - private _isCreatingQuery = (queryName: string): boolean => { - for (const tagId of this._creatingTagIds) { - if (queryName.startsWith(createQueryName(tagId))) { - return true; + private _isQueryOwned = ( + querySerialOrInfo: QuerySerial | QueryInfo + ): boolean => { + const { name, serial } = + typeof querySerialOrInfo === 'object' + ? querySerialOrInfo + : { serial: querySerialOrInfo }; + + if (name != null) { + for (const tagId of this._pendingQueryTagIds) { + // Created workers are named `IC - VS Code - ` (the iframe create + // flow may append a `_vN` version suffix), so a prefix match against + // in-flight tagIds covers both forms + const namePrefix = createQueryName(tagId); + + if (name.startsWith(namePrefix)) { + return true; + } } } - return false; + + return this._querySerialSet.has(serial as QuerySerial); + }; + + private _isQueryTracked = ({ serial }: QueryInfo): boolean => { + return [...this._workerInfoMap.values()].some(w => w.serial === serial); }; /** @@ -395,7 +393,8 @@ export class DheService implements IDheService { // the `finally` below, by which point its serial is in `_querySerialSet` // (added synchronously right after, with no `await` in between), so the // serial-based guard takes over without a gap. - this._creatingTagIds.add(tagId); + this._pendingQueryTagIds.add(tagId); + const removeStartupFailureListener = dheClient.client.addEventListener( dhe.Client.EVENT_CONFIG_UPDATED, ({ detail: queryInfo }: CustomEvent) => { @@ -444,7 +443,7 @@ export class DheService implements IDheService { throw err; } finally { removeStartupFailureListener(); - this._creatingTagIds.delete(tagId); + this._pendingQueryTagIds.delete(tagId); } if (querySerial == null) { @@ -452,7 +451,7 @@ export class DheService implements IDheService { } this._querySerialSet.add(querySerial); - const workerInfo = await getWorkerInfoFromQuery( + const workerInfo = await getWorkerInfoFromQuerySerial( tagId, dhe, dheClient.client, @@ -468,35 +467,40 @@ export class DheService implements IDheService { }; /** - * Attach to a pre-existing Running worker by building WorkerInfo from the - * given QueryInfo. Does NOT add the serial to `_querySerialSet` — attached + * Register a pre-existing Running worker by building WorkerInfo from the + * given QueryInfo. Does NOT add the serial to `_querySerialSet` — registered * workers are never owned and must never be deleted by the extension. - * @param queryInfo The already-Running QueryInfo for the worker to attach. - * @returns WorkerInfo for the attached worker. + * @param queryInfo The already-Running QueryInfo for the worker to register. + * @returns WorkerInfo for the registered worker. */ - attachWorker = (queryInfo: QueryInfo): WorkerInfo => { + registerWorkerInfo = (queryInfo: QueryInfo): WorkerInfo => { const tagId = uniqueId(); - const workerInfo = buildWorkerInfo(tagId, queryInfo); + const workerInfo = getWorkerInfoFromQueryInfo(tagId, queryInfo); if (workerInfo == null) { throw new Error( - `Cannot attach worker: queryInfo.designated is null for serial ${queryInfo.serial}` + `Cannot register worker for query info: ${queryInfo.serial}` ); } + this._workerInfoMap.set(workerInfo.workerUrl, workerInfo); + return workerInfo; }; /** * List all running InteractiveConsole workers owned by the current effective * user. + * @param exclude Iterable of query serials to exclude from the results. * @returns A promise resolving to the filtered QueryInfo array. */ - listAttachableWorkers = async (): Promise => { + listAttachableWorkers = async ( + exclude: Iterable + ): Promise => { const dheClient = await this.getClient(false); if (dheClient == null) { return []; } - return listAttachableWorkersHelper(dheClient.client); + return listAttachableWorkers(dheClient.client, exclude); }; /** @@ -511,12 +515,10 @@ export class DheService implements IDheService { return; } - const isOwned = this._querySerialSet.has(workerInfo.serial); - - this._querySerialSet.delete(workerInfo.serial); this._workerInfoMap.delete(workerUrl); - if (isOwned) { + if (this._isQueryOwned(workerInfo.serial)) { + this._querySerialSet.delete(workerInfo.serial); await this._disposeQueries([workerInfo.serial]); } }; @@ -544,8 +546,8 @@ export class DheService implements IDheService { this._querySerialSet.clear(); this._removeConfigListeners?.(); this._removeConfigListeners = null; - this._onDidWorkerAttachable.dispose(); - this._onDidWorkerRemoved.dispose(); + this._onWorkerAttachable.dispose(); + this._onWorkerRemoved.dispose(); await Promise.all([ this._workerInfoMap.dispose(), diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 0dfead421..02503503e 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -34,6 +34,7 @@ import { URIMap, URLMap, withResolvers, + type PromiseWithResolvers, } from '../util'; import { DhcService } from './DhcService'; import { getWorkerCredentials, isDheServerRunning } from '../dh/dhe'; @@ -55,7 +56,10 @@ export class ServerManager implements IServerManager { ) { this._configService = configService; this._connectionMap = new URLMap(); - this._pendingConnectionMap = new URLMap>(); + this._pendingConnectionMap = new URLMap< + PromiseWithResolvers + >(); + this._pendingServerConnections = new URLMap>(); this._coreClientCache = coreClientCache; this._dhcServiceFactory = dhcServiceFactory; this._dheClientCache = dheClientCache; @@ -77,7 +81,10 @@ export class ServerManager implements IServerManager { private readonly _configService: IConfigService; private readonly _connectionMap: URLMap; private readonly _pendingConnectionMap: URLMap< - Promise + PromiseWithResolvers + >; + private readonly _pendingServerConnections: URLMap< + PromiseWithResolvers >; private readonly _coreClientCache: URLMap; private readonly _dhcServiceFactory: IDhcServiceFactory; @@ -109,22 +116,27 @@ export class ServerManager implements IServerManager { private readonly _onDidUpdate = new vscode.EventEmitter(); readonly onDidUpdate = this._onDidUpdate.event; - private _setPendingConnection = ( + private _resolvePendingConnection = ( serverUrl: URL, - connectionPromise: Promise + connectionState: ConnectionState | null ): void => { - this._pendingConnectionMap.set(serverUrl, connectionPromise); - this._onDidUpdate.fire(); + if (this._pendingConnectionMap.has(serverUrl)) { + this._pendingConnectionMap.getOrThrow(serverUrl).resolve(connectionState); + this._pendingConnectionMap.delete(serverUrl); + this._onDidUpdate.fire(); + } }; - private _clearPendingConnection = (serverUrl: URL): void => { - if (this._pendingConnectionMap.delete(serverUrl)) { + private _resolvePendingServerConnection = (serverUrl: URL): void => { + if (this._pendingServerConnections.has(serverUrl)) { + this._pendingServerConnections.getOrThrow(serverUrl).resolve(); + this._pendingServerConnections.delete(serverUrl); this._onDidUpdate.fire(); } }; - isConnecting = (serverUrl: URL): boolean => - this._pendingConnectionMap.has(serverUrl); + isServerConnecting = (serverUrl: URL): boolean => + this._pendingServerConnections.has(serverUrl); private _lastServerRunningStatus = new URLMap(); private _hasEverUpdatedStatus = false; @@ -219,6 +231,16 @@ export class ServerManager implements IServerManager { this._onDidLoadConfig.fire(); }; + /** + * Connect to a server and attach to any Core / Core+ workers available for + * the server. For DHE, if no workers are available, creates one and attaches + * to it. + * @param serverUrl + * @param workerConsoleType + * @param operateAsAnotherUser + * @returns The connection state of the attached worker, or `null` if the + * connection or worker creation/attachment failed. + */ connectToServer = async ( serverUrl: URL, workerConsoleType?: ConsoleType, @@ -230,7 +252,7 @@ export class ServerManager implements IServerManager { throw new Error(`Server with URL '${serverUrl}' not found.`); } - // We only support 1 connection for DHC servers in the extension + // We only support 1 connection to a DHC server if (serverState.type === 'DHC' && serverState.connectionCount > 0) { logger.info('Already connected to server:', serverUrl.href); return this._connectionMap.getOrThrow(serverUrl); @@ -238,7 +260,7 @@ export class ServerManager implements IServerManager { if (this._pendingConnectionMap.has(serverUrl)) { logger.debug('Connection already in progress:', serverUrl.href); - return this._pendingConnectionMap.getOrThrow(serverUrl); + return this._pendingConnectionMap.getOrThrow(serverUrl).promise; } return this._doConnectToServer( @@ -250,186 +272,130 @@ export class ServerManager implements IServerManager { private _doConnectToServer = async ( serverState: ServerState, - workerConsoleType?: ConsoleType, - operateAsAnotherUser: boolean = false + workerConsoleType: ConsoleType | undefined = undefined, + operateAsAnotherUser: boolean ): Promise => { const serverUrl = serverState.url; logger.debug('Connecting to server:', serverUrl.href); - // Register the in-flight connection synchronously — before the first - // await — so concurrent callers dedupe against it. The deferred is settled - // with the result once the connect completes (see `finally`). - const { promise, resolve } = withResolvers(); - this._setPendingConnection(serverUrl, promise); + // Mark server and worker connection as pending + this._pendingServerConnections.set(serverUrl, withResolvers()); + this._pendingConnectionMap.set(serverUrl, withResolvers()); + this._onDidUpdate.fire(); + + let firstConnection: ConnectionState | null = null; - let result: ConnectionState | null = null; try { if (serverState.type === 'DHE') { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); - - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } + const dheService = await this._connectToDheServer( + serverUrl, + operateAsAnotherUser + ); - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } + // server connection is done + this._resolvePendingServerConnection(serverUrl); - // Client is live; the DHE connecting window ends here. The worker - // attach/create phase below is intentionally not covered (the `finally` - // clear is then an idempotent no-op). - this._clearPendingConnection(dheServerUrl); - - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onDidWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) - ); - dheService.onDidWorkerRemoved(serial => - this._reconcileDetach(serial) - ); + if (dheService == null) { + return null; } - // Snapshot currently-running attachable workers and compute the - // not-yet-attached subset. - const attachable = await dheService.listAttachableWorkers(); - const toAttach = attachable.filter( - qi => !this._attachedWorkerSerials.has(qi.serial as QuerySerial) + const attachableWorkers = await dheService.listAttachableWorkers( + this._attachedWorkerSerials.keys() ); - if (toAttach.length > 0) { - // Attach existing workers in batches to avoid server stampede. - const BATCH_SIZE = 4; - let firstConnection: ConnectionState | null = null; - for (let i = 0; i < toAttach.length; i += BATCH_SIZE) { - const batch = toAttach.slice(i, i + BATCH_SIZE); - const connections = await Promise.all( - batch.map(async qi => { - const workerInfo = dheService.attachWorker(qi); - return this._connectWorker(dheServerUrl, workerInfo); - }) - ); - if (firstConnection == null) { - firstConnection = connections.find(c => c != null) ?? null; - } - } - if (toAttach.length > 1) { - this._toaster.info(`Attached to ${toAttach.length} worker(s).`); - } - result = firstConnection; - return result; - } - - // Nothing left to attach — either no workers exist for this user, or all - // of them already have live connections. Create a fresh one so clicking a - // server always yields a usable session. - const tagId = uniqueId(); - const placeholderUrl = this.addWorkerPlaceholderConnection( - dheServerUrl, - tagId - ); + let workerInfos: WorkerInfo[]; - let workerInfo: WorkerInfo; - try { - workerInfo = await dheService.createWorker(tagId, workerConsoleType); + // If no attachable workers exist, create a new one + if (attachableWorkers.length === 0) { + const workerInfo = await this._createWorker( + dheService, + workerConsoleType + ); - // If the worker finished creating but there is no placeholder - // connection, the user cancelled before it was ready. - if (!this._connectionMap.has(placeholderUrl)) { - dheService.deleteWorker(workerInfo.workerUrl); - this._onDidUpdate.fire(); + if (workerInfo == null) { return null; } - } catch (err) { - if (err instanceof QueryCreationCancelledError) { - logger.info(err); - const msg = 'Connection cancelled.'; - this._outputChannel.appendLine(msg); - this._toaster.info(msg); - } else { - const msg = - err instanceof QueryStartupFailureError - ? err.message - : 'Failed to create worker.'; - logger.error(err); - this._outputChannel.appendLine(msg); - this._toaster.error(msg); - } - this.removeWorkerPlaceholderConnection(placeholderUrl); - return null; + workerInfos = [workerInfo]; + } + // register attachable workers + else { + workerInfos = attachableWorkers.map(queryInfo => + dheService.registerWorkerInfo(queryInfo) + ); } - this.removeWorkerPlaceholderConnection(placeholderUrl); - result = await this._connectWorker(dheServerUrl, workerInfo); - return result; - } - - // DHC path: single direct connection. - const connection = this._dhcServiceFactory.create(serverUrl, undefined); + // Connect to workers in parallel + const connections = ( + await Promise.all( + workerInfos.map(workerInfo => + this._attachToWorker(serverUrl, workerInfo) + ) + ) + ).filter(c => c != null); + + if (attachableWorkers.length > 1) { + this._toaster.info(`Attached to ${connections.length} worker(s).`); + } - // Initialize client + prompt for login if necessary - const coreClient = await connection.getClient(); + [firstConnection = null] = connections; - if (coreClient == null) { - return null; + return firstConnection; } - this._connectionMap.set(serverUrl, connection); - this._onDidUpdate.fire(); - - if (!(await connection.initSession())) { - this._coreClientCache.delete(serverUrl); + // DHC attach to Core worker + firstConnection = await this._attachToWorker(serverUrl); - connection.dispose(); - this._connectionMap.delete(serverUrl); - return null; - } + this._resolvePendingServerConnection(serverUrl); + } finally { + this._resolvePendingConnection(serverUrl, firstConnection); + } - connection.onDidDisconnect(() => { - logger.debug('onDidDisconnect fired for:', serverUrl.href); - this.disconnectFromServer(serverUrl); - }); + return firstConnection; + }; - connection.onDidChangeRunningCodeStatus?.(() => { - this._onDidUpdate.fire(); - }); + private _connectToDheServer = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); - this.updateConnectionCount(serverUrl, 1); + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } - this._onDidConnect.fire(serverUrl); + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); this._onDidUpdate.fire(); + } - result = this._connectionMap.get(serverUrl) ?? null; - return result; - } finally { - // Settle the deferred for any deduped caller, and clear the pending - // entry. The clear ends the connecting window — DHC: the whole connect; - // DHE: idempotent here, since the explicit clear above already ran once - // the client was established (this still covers DHE failures before - // establishment). - resolve(result); - this._clearPendingConnection(serverUrl); + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); } + + return dheService; }; /** @@ -437,38 +403,45 @@ export class ServerManager implements IServerManager { * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` * for idempotency/teardown. Used by both the create path and the attach path. * Does NOT touch placeholder connections — that is the create path's concern. - * @param dheServerUrl The DHE server this worker belongs to. + * @param serverUrl The DHE server this worker belongs to. * @param workerInfo Worker info built from the query. * @returns The new connection state, or null on failure. */ - private _connectWorker = async ( - dheServerUrl: URL, - workerInfo: WorkerInfo + private _attachToWorker = async ( + serverUrl: URL, + workerInfo?: WorkerInfo ): Promise => { - const workerUrl = new URL(workerInfo.workerUrl); - - // Idempotency gate: reserve the serial synchronously, before any `await`, - // so concurrent attach attempts for the same worker — e.g. the initial - // enumeration racing a streaming config event, or two clicks on the same - // server — cannot both connect and double-count. A later failure rolls the - // reservation back so the worker can be retried. - if (this._attachedWorkerSerials.has(workerInfo.serial)) { - return this._connectionMap.get(workerUrl) ?? null; - } - this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); + const workerUrl = + workerInfo == null ? serverUrl : new URL(workerInfo.workerUrl); + + if (workerInfo != null) { + // Idempotency gate: reserve the serial synchronously, before any `await`, + // so concurrent attach attempts for the same worker — e.g. the initial + // enumeration racing a streaming config event, or two clicks on the same + // server — cannot both connect and double-count. A later failure rolls the + // reservation back so the worker can be retried. + if (this._attachedWorkerSerials.has(workerInfo.serial)) { + return this._connectionMap.get(workerUrl) ?? null; + } + this._attachedWorkerSerials.set(workerInfo.serial, workerInfo.workerUrl); - // Map the worker URL to its DHE server so the auth flow can resolve creds. - this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + // Map the worker URL to its DHE server so the auth flow can resolve creds. + this._workerURLToServerURLMap.set(workerUrl, serverUrl); + } const connection = this._dhcServiceFactory.create( workerUrl, - workerInfo.tagId + workerInfo?.tagId ); - // Initialize client (drives getWorkerCredentials → createAuthToken). + // Initialize client (includes auth flow). const coreClient = await connection.getClient(); + if (coreClient == null) { - this._attachedWorkerSerials.delete(workerInfo.serial); + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + return null; } @@ -476,8 +449,12 @@ export class ServerManager implements IServerManager { this._onDidUpdate.fire(); if (!(await connection.initSession())) { - this._attachedWorkerSerials.delete(workerInfo.serial); + if (workerInfo != null) { + this._attachedWorkerSerials.delete(workerInfo.serial); + } + this._coreClientCache.delete(workerUrl); + connection.dispose(); this._connectionMap.delete(workerUrl); return null; @@ -492,7 +469,7 @@ export class ServerManager implements IServerManager { this._onDidUpdate.fire(); }); - this.updateConnectionCount(dheServerUrl, 1); + this.updateConnectionCount(serverUrl, 1); this._onDidConnect.fire(workerUrl); this._onDidUpdate.fire(); @@ -500,6 +477,53 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(workerUrl) ?? null; }; + private _createWorker = async ( + dheService: IDheService, + workerConsoleType?: ConsoleType + ): Promise => { + const tagId = uniqueId(); + const placeholderUrl = this.addWorkerPlaceholderConnection( + dheService.serverUrl, + tagId + ); + + try { + const workerInfo = await dheService.createWorker( + tagId, + workerConsoleType + ); + + // If the worker finished creating but there is no placeholder + // connection, the user cancelled before it was ready. + if (!this._connectionMap.has(placeholderUrl)) { + dheService.deleteWorker(workerInfo.workerUrl); + this._onDidUpdate.fire(); + return null; + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return workerInfo; + } catch (err) { + if (err instanceof QueryCreationCancelledError) { + logger.info(err); + const msg = 'Connection cancelled.'; + this._outputChannel.appendLine(msg); + this._toaster.info(msg); + } else { + const msg = + err instanceof QueryStartupFailureError + ? err.message + : 'Failed to create worker.'; + logger.error(err); + this._outputChannel.appendLine(msg); + this._toaster.error(msg); + } + + this.removeWorkerPlaceholderConnection(placeholderUrl); + return null; + } + }; + /** * Attach a single worker when a config event indicates it became attachable. * Idempotent: no-ops if the serial is already connected. @@ -512,8 +536,8 @@ export class ServerManager implements IServerManager { if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { return; } - const workerInfo = dheService.attachWorker(queryInfo); - await this._connectWorker(dheServerUrl, workerInfo); + const workerInfo = dheService.registerWorkerInfo(queryInfo); + await this._attachToWorker(dheServerUrl, workerInfo); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 1566234f9..e95e55352 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -81,8 +81,8 @@ export interface IDhcService extends IDisposable, ConnectionState { } export interface IDheService extends ConnectionState, IDisposable { - readonly onDidWorkerAttachable: vscode.Event; - readonly onDidWorkerRemoved: vscode.Event; + readonly onWorkerAttachable: vscode.Event; + readonly onWorkerRemoved: vscode.Event; getClient( initializeIfNull: false @@ -94,8 +94,10 @@ export interface IDheService extends ConnectionState, IDisposable { getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; - attachWorker: (queryInfo: QueryInfo) => WorkerInfo; - listAttachableWorkers: () => Promise; + registerWorkerInfo: (queryInfo: QueryInfo) => WorkerInfo; + listAttachableWorkers: ( + exclude: Iterable + ) => Promise; createWorker: ( tagId: UniqueID, consoleType?: ConsoleType @@ -188,7 +190,7 @@ export interface IServerManager extends IDisposable { hasConnectionUris: (connection: ConnectionState) => boolean; /** Whether a client connection to the given server is currently being established. */ - isConnecting: (serverUrl: URL) => boolean; + isServerConnecting: (serverUrl: URL) => boolean; getConnection: (serverUrl: URL) => ConnectionState | undefined; getConnections: (serverOrWorkerUrl?: URL) => ConnectionState[]; From cd658af3b0aab1c2c55ac7c6aaf2dc6fdaa4005f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:41:46 -0500 Subject: [PATCH 11/68] Added Create Worker action on DHE server nodes (#DH-22593) Add an explicit "+" (Create Worker) inline action to DHE server nodes in the WORKERS tree. It always creates a new IC worker on the server and attaches to it, independent of how many workers already exist. - ServerManager.createWorker composes _createWorker + _attachToWorker. - getConnectionTreeRootNodes also renders connected DHE servers so the server node (and its action) stays reachable with zero workers. - CREATE_WORKER_CMD + CreateWorkerCmdArgs, package.json command/palette/ view-item-context wiring, and ConnectionController.onCreateWorker. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- package.json | 14 ++++++++++++ src/common/commands.ts | 6 +++++ src/controllers/ConnectionController.ts | 23 +++++++++++++++++++ src/services/ServerManager.ts | 30 +++++++++++++++++++++++++ src/types/serviceTypes.d.ts | 4 ++++ src/util/treeViewUtils.ts | 16 ++++++++++++- 6 files changed, 92 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index f87366fb6..bc7690e45 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,11 @@ "title": "Connect to Server as Another User", "icon": "$(account)" }, + { + "command": "vscode-deephaven.createWorker", + "title": "Create Worker", + "icon": "$(add)" + }, { "command": "vscode-deephaven.createNewTextDoc", "title": "New File", @@ -835,6 +840,10 @@ "command": "vscode-deephaven.connectToServerOperateAs", "when": "false" }, + { + "command": "vscode-deephaven.createWorker", + "when": "false" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "false" @@ -1009,6 +1018,11 @@ "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected", "group": "inline@1" }, + { + "command": "vscode-deephaven.createWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isDHEServerConnectionParent", + "group": "inline" + }, { "command": "vscode-deephaven.disconnectEditor", "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isUri", diff --git a/src/common/commands.ts b/src/common/commands.ts index ee2042f83..b52a08018 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -26,6 +26,11 @@ export type ConnectToServerCmdArgs = [ operateAsAnotherUser?: boolean, ]; +/** Arguments passed to `CREATE_WORKER_CMD` handler */ +export type CreateWorkerCmdArgs = [ + serverState: Pick, +]; + /** Arguments passed to `OPEN_VARIABLE_PANELS_CMD` handler */ export type OpenVariablePanelsCmdArgs = [ serverUrl: URL, @@ -97,6 +102,7 @@ export const CREATE_DHE_AUTHENTICATED_CLIENT_CMD = cmd( 'createDHEAuthenticatedClient' ); export const CREATE_NEW_TEXT_DOC_CMD = cmd('createNewTextDoc'); +export const CREATE_WORKER_CMD = cmd('createWorker'); export const DELETE_VARIABLE_CMD = cmd('deleteVariable'); export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 264f51f05..6c9927ff9 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -24,6 +24,8 @@ import { CONNECT_TO_SERVER_CMD, CONNECT_TO_SERVER_OPERATE_AS_CMD, ConnectToServerCmdArgs, + CREATE_WORKER_CMD, + CreateWorkerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, SELECT_CONNECTION_COMMAND, @@ -66,6 +68,9 @@ export class ConnectionController this.onConnectToServerOperateAs ); + /** Create a new worker on a DHE server */ + this.registerCommand(CREATE_WORKER_CMD, this.onCreateWorker); + /** Disconnect editor */ this.registerCommand(DISCONNECT_EDITOR_CMD, this.onDisconnectEditor); @@ -338,6 +343,24 @@ export class ConnectionController ); }; + /** + * Handle explicitly creating a new worker on a DHE server (the "+" action). + */ + onCreateWorker = async ( + ...[serverState]: CreateWorkerCmdArgs | [undefined] + ): Promise => { + // Sometimes view/item/context commands pass undefined instead of a value. + // Just ignore. microsoft/vscode#283655 + if (serverState == null) { + return; + } + + const languageId = vscode.window.activeTextEditor?.document.languageId; + const workerConsoleType = getConsoleType(languageId); + + await this._serverManager?.createWorker(serverState.url, workerConsoleType); + }; + /** * Handle connecting to a server as another user. * @param serverState diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 02503503e..4c808a8c7 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -524,6 +524,36 @@ export class ServerManager implements IServerManager { } }; + /** + * Explicitly create a new worker on a DHE server and attach to it. Unlike + * `connectToServer` (which only auto-creates a worker when none are + * attachable), this always creates a worker — it backs the "+" create-worker + * action on DHE server nodes. + * @param dheServerUrl The DHE server to create the worker on. + * @param workerConsoleType Optional console type for the new worker. + * @returns The new connection state, or `null` if client/worker + * creation/attachment failed. + */ + createWorker = async ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType + ): Promise => { + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Ensure a live client (the server is already connected when the "+" action + // is visible, but guard anyway — this also covers a stale/expired client). + if (!(await dheService.getClient(true, false))) { + return null; + } + + const workerInfo = await this._createWorker(dheService, workerConsoleType); + if (workerInfo == null) { + return null; + } + + return this._attachToWorker(dheServerUrl, workerInfo); + }; + /** * Attach a single worker when a config event indicates it became attachable. * Idempotent: no-ops if the serial is already connected. diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index e95e55352..39c298231 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -182,6 +182,10 @@ export interface IServerManager extends IDisposable { workerConsoleType?: ConsoleType, operateAsAnotherUser?: boolean ) => Promise; + createWorker: ( + dheServerUrl: URL, + workerConsoleType?: ConsoleType + ) => Promise; disconnectEditor: (uri: vscode.Uri) => void; disconnectFromDHEServer: (dheServerUrl: URL) => Promise; disconnectFromServer: (serverUrl: URL) => Promise; diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 422c887eb..5ea4e4083 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -169,8 +169,13 @@ export function getConnectionWorkerLabel( * connection is grouped under its parent server node (DHC and DHE alike), so a * community server with a single worker has the same hierarchy shape as an * enterprise server with many. Roots are sorted by their displayed label. + * + * In addition to every server that has connections, any DHE server with a live + * client is included so its node (and its "+" create-worker action) stays + * reachable even with zero workers. * @param serverManager Server manager. - * @returns The server root nodes (only servers that have connections). + * @returns The server root nodes (servers with connections, plus connected DHE + * servers). */ export function getConnectionTreeRootNodes( serverManager: IServerManager @@ -184,6 +189,15 @@ export function getConnectionTreeRootNodes( } } + // DHE servers with a live client appear even with zero workers, so the server + // node (and its "+" create-worker action) stays reachable after a cancelled + // worker creation or after the last worker is detached. + for (const server of serverManager.getServers({ type: 'DHE' })) { + if (server.isConnected) { + servers.set(server.url.toString(), server); + } + } + return [...servers.values()].sort((a, b) => getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) ); From 950bdcdf7ee6a08304ee3fa57d94e41a79c44cc5 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Wed, 17 Jun 2026 16:45:04 -0500 Subject: [PATCH 12/68] Align connection picker active items with WORKERS tree (#DH-22593) Active-connection picker items now mirror the WORKERS-tree worker node: leading language icon (getConsoleTypeIconId) + worker-name label (getConnectionWorkerLabel), with the server host:port moved to the description. createConnectionQuickPickOptions is now async and takes an IServerManager to resolve each connection's parent server and worker info. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- src/controllers/ConnectionController.ts | 3 +- src/util/__snapshots__/uiUtils.spec.ts.snap | 24 ++++----- src/util/uiUtils.spec.ts | 37 ++++++++++--- src/util/uiUtils.ts | 59 ++++++++++++++------- 4 files changed, 86 insertions(+), 37 deletions(-) diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 6c9927ff9..5b105f820 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -500,10 +500,11 @@ export class ConnectionController try { selectedCnResult = await createConnectionQuickPick( - createConnectionQuickPickOptions( + await createConnectionQuickPickOptions( [...runningDHCServersWithoutConnections, ...runningDHEServers], connectionsForConsoleType, languageId, + this._serverManager, editorActiveConnectionUrl ) ); diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap index 9c7e875cf..9009195c7 100644 --- a/src/util/__snapshots__/uiUtils.spec.ts.snap +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -33,12 +33,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10000/", }, - "description": "python (current)", + "description": "localhost:10000 (current)", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "localhost:10000", "type": "connection", }, { @@ -46,12 +46,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "localhost:10002", "type": "connection", }, { @@ -104,12 +104,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10000/", }, - "description": "python", + "description": "localhost:10000", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10000/", + "label": "localhost:10000", "type": "connection", }, { @@ -117,12 +117,12 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "isConnected": true, "serverUrl": "http://localhost:10002/", }, - "description": "python", + "description": "localhost:10002", "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "dh-python", }, - "label": "http://localhost:10002/", + "label": "localhost:10002", "type": "connection", }, { diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts index c78b4fb54..575f53f5b 100644 --- a/src/util/uiUtils.spec.ts +++ b/src/util/uiUtils.spec.ts @@ -14,6 +14,7 @@ import type { ConnectionState, CoreConnectionConfig, IDhcService, + IServerManager, ServerState, } from '../types'; @@ -55,7 +56,7 @@ describe('createConnectionQuickPickOptions', () => { ['Active A', serverUrlA], ])( 'should return quick pick options: editorActiveConnectionUrl=%s', - (_label, editorActiveConnectionUrl) => { + async (_label, editorActiveConnectionUrl) => { const serversWithoutConnections: ServerState[] = [ { type: 'DHC', @@ -77,23 +78,47 @@ describe('createConnectionQuickPickOptions', () => { { serverUrl: serverUrlC, isConnected: true }, ]; - const actual = createConnectionQuickPickOptions( + const serverManager = { + getServerForConnection: vi.fn( + (connection: ConnectionState): ServerState => ({ + type: 'DHC', + url: connection.serverUrl, + isConnected: true, + isRunning: true, + connectionCount: 1, + }) + ), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + const actual = await createConnectionQuickPickOptions( serversWithoutConnections, connections, 'python', + serverManager, editorActiveConnectionUrl ); expect(actual).toMatchSnapshot(); } ); - it('should throw if no servers or connections', () => { + it('should throw if no servers or connections', async () => { const servers: ServerState[] = []; const connections: IDhcService[] = []; - expect(() => - createConnectionQuickPickOptions(servers, connections, 'python') - ).toThrowError('No available servers to connect to.'); + const serverManager = { + getServerForConnection: vi.fn(), + getWorkerInfo: vi.fn(async () => undefined), + } as unknown as IServerManager; + + await expect( + createConnectionQuickPickOptions( + servers, + connections, + 'python', + serverManager + ) + ).rejects.toThrowError('No available servers to connect to.'); }); }); diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 53ceaa19f..5645fc586 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -19,6 +19,7 @@ import type { ConnectionType, ConsoleType, ConnectionPickItem, + IServerManager, ServerState, SeparatorPickItem, ConnectionPickOption, @@ -32,6 +33,7 @@ import type { MultiAuthConfig, } from '../types'; import { getFilePathDateToken, sortByStringProp } from './dataUtils'; +import { getConnectionWorkerLabel, getConsoleTypeIconId } from './treeViewUtils'; import { Logger } from './Logger'; const logger = new Logger('uiUtils'); @@ -54,20 +56,27 @@ export interface WorkspaceFolderConfig { /** * Create options for a connection quick pick. + * + * Active-connection items mirror the WORKERS-tree worker node: a leading + * language icon plus the worker name, with the server host:port shown in the + * description. * @param servers The available servers * @param connections The available connections * @param editorLanguageId The language id of the editor + * @param serverManager The server manager used to resolve each connection's + * parent server and worker info. * @param editorActiveConnectionUrl The active connection url of the editor * @returns */ -export function createConnectionQuickPickOptions< +export async function createConnectionQuickPickOptions< TConnection extends ConnectionState, >( servers: ServerState[], connections: TConnection[], editorLanguageId: string, + serverManager: IServerManager, editorActiveConnectionUrl?: URL | null -): ConnectionPickOption[] { +): Promise[]> { const serverOptions: ConnectionPickItem<'server', ServerState>[] = servers.map(data => ({ type: 'server', @@ -77,22 +86,36 @@ export function createConnectionQuickPickOptions< data, })); - const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = []; - - for (const dhService of connections) { - const isActiveConnection = - editorActiveConnectionUrl?.toString() === dhService.serverUrl.toString(); - - connectionOptions.push({ - type: 'connection', - label: dhService.serverUrl.toString(), - iconPath: new vscode.ThemeIcon(ICON_ID.connected), - description: isActiveConnection - ? `${editorLanguageId} (current)` - : editorLanguageId, - data: dhService, - }); - } + const connectionOptions: ConnectionPickItem<'connection', TConnection>[] = + await Promise.all( + connections.map(async dhService => { + const isActiveConnection = + editorActiveConnectionUrl?.toString() === + dhService.serverUrl.toString(); + + const parentServer = + serverManager.getServerForConnection(dhService); + const workerInfo = await serverManager.getWorkerInfo( + dhService.serverUrl + ); + + const host = parentServer?.url.host ?? dhService.serverUrl.host; + + return { + type: 'connection' as const, + label: getConnectionWorkerLabel( + parentServer, + dhService, + workerInfo?.name + ), + iconPath: new vscode.ThemeIcon( + getConsoleTypeIconId(editorLanguageId as ConsoleType) + ), + description: isActiveConnection ? `${host} (current)` : host, + data: dhService, + }; + }) + ); if (serverOptions.length === 0 && connectionOptions.length === 0) { throw new Error('No available servers to connect to.'); From 80434d6c05b22e37fe4654e759e8f9d92e6540d1 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 10:53:00 -0500 Subject: [PATCH 13/68] Only allow removing owned connections (#DH-22593) --- package.json | 20 +- src/common/commands.ts | 1 + src/common/constants.ts | 6 +- src/controllers/ConnectionController.ts | 7 + src/providers/ServerConnectionTreeProvider.ts | 7 +- src/services/DhcService.ts | 10 +- src/services/ServerManager.ts | 193 ++++++++++-------- src/types/serviceTypes.d.ts | 3 +- 8 files changed, 148 insertions(+), 99 deletions(-) diff --git a/package.json b/package.json index bc7690e45..7ff79d550 100644 --- a/package.json +++ b/package.json @@ -265,6 +265,11 @@ "title": "Disconnect from Server", "icon": "$(debug-disconnect)" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "title": "Disconnect from Worker", + "icon": "$(debug-disconnect)" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "title": "Generate DHE Key Pair", @@ -852,6 +857,10 @@ "command": "vscode-deephaven.disconnectFromServer", "when": "false" }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "false" + }, { "command": "vscode-deephaven.generateDHEKeyPair", "when:": "false" @@ -1015,7 +1024,7 @@ }, { "command": "vscode-deephaven.createNewTextDoc", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)", "group": "inline@1" }, { @@ -1030,7 +1039,12 @@ }, { "command": "vscode-deephaven.disconnectFromServer", - "when": "(view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)) || (view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnecting))", + "when": "view == vscode-deephaven.view.serverTree && (viewItem == isServerRunningConnected || viewItem == isDHEServerRunningConnected)", + "group": "inline@2" + }, + { + "command": "vscode-deephaven.disconnectFromWorker", + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnectedRemovable || viewItem == isConnectionConnectingRemovable)", "group": "inline@2" }, { @@ -1039,7 +1053,7 @@ }, { "command": "vscode-deephaven.generateRequirementsTxt", - "when": "view == vscode-deephaven.view.serverConnectionTree && viewItem == isConnectionConnected" + "when": "view == vscode-deephaven.view.serverConnectionTree && (viewItem == isConnectionConnected || viewItem == isConnectionConnectedRemovable)" }, { "command": "vscode-deephaven.openInBrowser", diff --git a/src/common/commands.ts b/src/common/commands.ts index b52a08018..3f78a953e 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -106,6 +106,7 @@ export const CREATE_WORKER_CMD = cmd('createWorker'); export const DELETE_VARIABLE_CMD = cmd('deleteVariable'); export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); +export const DISCONNECT_FROM_WORKER_CMD = cmd('disconnectFromWorker'); export const DOWNLOAD_LOGS_CMD = cmd('downloadLogs'); export const GENERATE_DHE_KEY_PAIR_CMD = cmd('generateDHEKeyPair'); export const GENERATE_REQUIREMENTS_TXT_CMD = cmd('generateRequirementsTxt'); diff --git a/src/common/constants.ts b/src/common/constants.ts index 827be37dc..90a597b66 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -157,8 +157,10 @@ export const VARIABLE_UNICODE_ICONS = { /* eslint-enable @typescript-eslint/naming-convention */ export const CONNECTION_TREE_ITEM_CONTEXT = { - isConnectionConnected: 'isConnectionConnected', - isConnectionConnecting: 'isConnectionConnecting', + isConnectionConnected: (isOwned: boolean) => + `isConnectionConnected${isOwned ? 'Removable' : ''}`, + isConnectionConnecting: (isOwned: boolean) => + `isConnectionConnecting${isOwned ? 'Removable' : ''}`, isDHEServerConnectionParent: 'isDHEServerConnectionParent', isUri: 'isUri', } as const; diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 5b105f820..514fcc221 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -28,6 +28,7 @@ import { CreateWorkerCmdArgs, DISCONNECT_EDITOR_CMD, DISCONNECT_FROM_SERVER_CMD, + DISCONNECT_FROM_WORKER_CMD, SELECT_CONNECTION_COMMAND, UnsupportedConsoleTypeError, } from '../common'; @@ -80,6 +81,12 @@ export class ConnectionController this.onDisconnectFromServer ); + /** Disconnect from worker (per-worker action on connection nodes) */ + this.registerCommand( + DISCONNECT_FROM_WORKER_CMD, + this.onDisconnectFromServer + ); + /** Select connection to run scripts against */ this.registerCommand( SELECT_CONNECTION_COMMAND, diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index 941bb1c27..221880183 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -59,12 +59,15 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase { return { - create: (serverUrl: URL, tagId?: UniqueID): IDhcService => { + create: ( + serverUrl: URL, + isOwned: boolean, + tagId?: UniqueID + ): IDhcService => { return new DhcService( serverUrl, + isOwned, coreClientCache, groovyDiagnosticsCollection, diagnosticsCollection, @@ -95,6 +100,7 @@ export class DhcService extends DisposableBase implements IDhcService { */ private constructor( serverUrl: URL, + isOwned: boolean, coreClientCache: URLMap, groovyDiagnosticsCollection: vscode.DiagnosticCollection, diagnosticsCollection: vscode.DiagnosticCollection, @@ -108,6 +114,7 @@ export class DhcService extends DisposableBase implements IDhcService { super(); this.coreClientCache = coreClientCache; + this.isOwned = isOwned; this.groovyDiagnosticsCollection = groovyDiagnosticsCollection; this.diagnosticsCollection = diagnosticsCollection; this.remoteFileSourceService = remoteFileSourceService; @@ -132,6 +139,7 @@ export class DhcService extends DisposableBase implements IDhcService { private readonly _onDidDisconnect = new vscode.EventEmitter(); readonly onDidDisconnect = this._onDidDisconnect.event; + public readonly isOwned: boolean; public readonly serverUrl: URL; public readonly tagId?: UniqueID; diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 4c808a8c7..10b9d5fec 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -287,7 +287,12 @@ export class ServerManager implements IServerManager { let firstConnection: ConnectionState | null = null; try { - if (serverState.type === 'DHE') { + if (serverState.type === 'DHC') { + // DHC attach to Core worker + firstConnection = await this._attachToWorker(serverUrl, true); + + this._resolvePendingServerConnection(serverUrl); + } else { const dheService = await this._connectToDheServer( serverUrl, operateAsAnotherUser @@ -300,54 +305,13 @@ export class ServerManager implements IServerManager { return null; } - const attachableWorkers = await dheService.listAttachableWorkers( - this._attachedWorkerSerials.keys() + [firstConnection = null] = await this._createOrAttachToWorkers( + dheService, + workerConsoleType ); - let workerInfos: WorkerInfo[]; - - // If no attachable workers exist, create a new one - if (attachableWorkers.length === 0) { - const workerInfo = await this._createWorker( - dheService, - workerConsoleType - ); - - if (workerInfo == null) { - return null; - } - - workerInfos = [workerInfo]; - } - // register attachable workers - else { - workerInfos = attachableWorkers.map(queryInfo => - dheService.registerWorkerInfo(queryInfo) - ); - } - - // Connect to workers in parallel - const connections = ( - await Promise.all( - workerInfos.map(workerInfo => - this._attachToWorker(serverUrl, workerInfo) - ) - ) - ).filter(c => c != null); - - if (attachableWorkers.length > 1) { - this._toaster.info(`Attached to ${connections.length} worker(s).`); - } - - [firstConnection = null] = connections; - return firstConnection; } - - // DHC attach to Core worker - firstConnection = await this._attachToWorker(serverUrl); - - this._resolvePendingServerConnection(serverUrl); } finally { this._resolvePendingConnection(serverUrl, firstConnection); } @@ -355,49 +319,6 @@ export class ServerManager implements IServerManager { return firstConnection; }; - private _connectToDheServer = async ( - serverUrl: URL, - operateAsAnotherUser: boolean - ): Promise => { - const dheServerUrl = serverUrl; - const isNewDheService = !this._dheServiceCache.has(dheServerUrl); - const dheService = await this._dheServiceCache.get(dheServerUrl); - - // Get client. Client will be initialized if it doesn't exist (including - // prompting user for login). - if (!(await dheService.getClient(true, operateAsAnotherUser))) { - return null; - } - - // Mark the DHE server as connected now that we have a live client. - // (connectionCount stays 0 until workers attach; isConnected reflects - // the server-level connection, which is now established.) - const currentServerState = this._serverMap.get(dheServerUrl); - if (currentServerState != null && !currentServerState.isConnected) { - this._serverMap.set(dheServerUrl, { - ...currentServerState, - isConnected: true, - }); - this._onDidUpdate.fire(); - } - - // Wire the config-event subscription exactly once per DHE service - // instance, BEFORE snapshotting, so any worker that appears or disappears - // between the snapshot and now is not missed. `_connectWorker` reserves - // each serial synchronously (before any `await`), so an event-driven - // attach and the snapshot batch below can never double-connect the same - // worker — whichever reaches `_connectWorker` first wins and the other is - // a no-op. - if (isNewDheService) { - dheService.onWorkerAttachable(qi => - this._reconcileAttach(dheServerUrl, dheService, qi) - ); - dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); - } - - return dheService; - }; - /** * Create a Core+ JS API connection to an existing worker. Populates * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` @@ -409,6 +330,7 @@ export class ServerManager implements IServerManager { */ private _attachToWorker = async ( serverUrl: URL, + isOwned: boolean, workerInfo?: WorkerInfo ): Promise => { const workerUrl = @@ -431,6 +353,7 @@ export class ServerManager implements IServerManager { const connection = this._dhcServiceFactory.create( workerUrl, + isOwned, workerInfo?.tagId ); @@ -477,6 +400,96 @@ export class ServerManager implements IServerManager { return this._connectionMap.get(workerUrl) ?? null; }; + private _connectToDheServer = async ( + serverUrl: URL, + operateAsAnotherUser: boolean + ): Promise => { + const dheServerUrl = serverUrl; + const isNewDheService = !this._dheServiceCache.has(dheServerUrl); + const dheService = await this._dheServiceCache.get(dheServerUrl); + + // Get client. Client will be initialized if it doesn't exist (including + // prompting user for login). + if (!(await dheService.getClient(true, operateAsAnotherUser))) { + return null; + } + + // Mark the DHE server as connected now that we have a live client. + // (connectionCount stays 0 until workers attach; isConnected reflects + // the server-level connection, which is now established.) + const currentServerState = this._serverMap.get(dheServerUrl); + if (currentServerState != null && !currentServerState.isConnected) { + this._serverMap.set(dheServerUrl, { + ...currentServerState, + isConnected: true, + }); + this._onDidUpdate.fire(); + } + + // Wire the config-event subscription exactly once per DHE service + // instance, BEFORE snapshotting, so any worker that appears or disappears + // between the snapshot and now is not missed. `_connectWorker` reserves + // each serial synchronously (before any `await`), so an event-driven + // attach and the snapshot batch below can never double-connect the same + // worker — whichever reaches `_connectWorker` first wins and the other is + // a no-op. + if (isNewDheService) { + dheService.onWorkerAttachable(qi => + this._reconcileAttach(dheServerUrl, dheService, qi) + ); + dheService.onWorkerRemoved(serial => this._reconcileDetach(serial)); + } + + return dheService; + }; + + private _createOrAttachToWorkers = async ( + dheService: IDheService, + workerConsoleType: ConsoleType | undefined = undefined + ): Promise => { + const attachableWorkers = await dheService.listAttachableWorkers( + this._attachedWorkerSerials.keys() + ); + + let workerInfos: [boolean, WorkerInfo][]; + + // If no attachable workers exist, create a new one + if (attachableWorkers.length === 0) { + const workerInfo = await this._createWorker( + dheService, + workerConsoleType + ); + + if (workerInfo == null) { + return []; + } + + workerInfos = [[true, workerInfo]]; + } + // register attachable workers + else { + workerInfos = attachableWorkers.map(queryInfo => [ + false, + dheService.registerWorkerInfo(queryInfo), + ]); + } + + // Connect to workers in parallel + const connections = ( + await Promise.all( + workerInfos.map(([isOwned, workerInfo]) => + this._attachToWorker(dheService.serverUrl, isOwned, workerInfo) + ) + ) + ).filter(c => c != null); + + if (attachableWorkers.length > 1) { + this._toaster.info(`Attached to ${connections.length} worker(s).`); + } + + return connections; + }; + private _createWorker = async ( dheService: IDheService, workerConsoleType?: ConsoleType @@ -551,7 +564,7 @@ export class ServerManager implements IServerManager { return null; } - return this._attachToWorker(dheServerUrl, workerInfo); + return this._attachToWorker(dheServerUrl, true, workerInfo); }; /** @@ -567,7 +580,7 @@ export class ServerManager implements IServerManager { return; } const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker(dheServerUrl, workerInfo); + await this._attachToWorker(dheServerUrl, false, workerInfo); }; /** diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 39c298231..7bea8e58e 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -58,6 +58,7 @@ export interface IConfigService { export interface IDhcService extends IDisposable, ConnectionState { readonly isInitialized: boolean; readonly isConnected: boolean; + readonly isOwned: boolean; isRunningCode: boolean; readonly onDidDisconnect: vscode.Event; @@ -118,7 +119,7 @@ export type ICoreClientFactory = ( */ export type IDhcServiceFactory = IFactory< IDhcService, - [serverUrl: URL, tagId?: UniqueID] + [serverUrl: URL, isOwned: boolean, tagId?: UniqueID] >; export type IDheClientFactory = ( serverUrl: URL From b7ab9b453a2657c81f26561d284273e35479c33f Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 12:17:41 -0500 Subject: [PATCH 14/68] Added label to ConnectionState (#DH-22593) --- .../ServerConnectionPanelTreeProvider.ts | 14 +----- src/providers/ServerConnectionTreeProvider.ts | 15 +----- src/services/DhcService.ts | 5 ++ src/services/DheService.ts | 15 ++++-- src/services/ServerManager.ts | 10 +++- src/types/commonTypes.d.ts | 1 + src/types/serviceTypes.d.ts | 2 +- src/util/treeViewUtils.spec.ts | 48 ------------------- src/util/treeViewUtils.ts | 22 --------- src/util/uiUtils.ts | 15 ++---- 10 files changed, 33 insertions(+), 114 deletions(-) diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 2ded30729..98369df70 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -8,7 +8,6 @@ import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, - getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, isServerStateNode, @@ -41,21 +40,10 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { return { create: ( + label: string, serverUrl: URL, isOwned: boolean, tagId?: UniqueID ): IDhcService => { return new DhcService( + label, serverUrl, isOwned, coreClientCache, @@ -99,6 +101,7 @@ export class DhcService extends DisposableBase implements IDhcService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, isOwned: boolean, coreClientCache: URLMap, @@ -115,6 +118,7 @@ export class DhcService extends DisposableBase implements IDhcService { this.coreClientCache = coreClientCache; this.isOwned = isOwned; + this.label = label; this.groovyDiagnosticsCollection = groovyDiagnosticsCollection; this.diagnosticsCollection = diagnosticsCollection; this.remoteFileSourceService = remoteFileSourceService; @@ -140,6 +144,7 @@ export class DhcService extends DisposableBase implements IDhcService { readonly onDidDisconnect = this._onDidDisconnect.event; public readonly isOwned: boolean; + public readonly label: string; public readonly serverUrl: URL; public readonly tagId?: UniqueID; diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 9cca2fb28..6e8c25029 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -64,15 +64,21 @@ export class DheService implements IDheService { toaster: IToastService ): IDheServiceFactory => { return { - create: (serverUrl: URL): IDheService => - new DheService( + create: (serverUrl: URL): IDheService => { + const serverConfig = configService + .getEnterpriseServers() + .find(server => server.url.href === serverUrl.href); + + return new DheService( + serverConfig?.label ?? serverUrl.href, serverUrl, configService, dheClientCache, dheJsApiCache, interactiveConsoleQueryFactory, toaster - ), + ); + }, }; }; @@ -81,6 +87,7 @@ export class DheService implements IDheService { * mechanism for instantiating. */ private constructor( + label: string, serverUrl: URL, configService: IConfigService, dheClientCache: URLMap, @@ -88,6 +95,7 @@ export class DheService implements IDheService { interactiveConsoleQueryFactory: IInteractiveConsoleQueryFactory, toaster: IToastService ) { + this.label = label; this.serverUrl = serverUrl; this._config = configService; this._dheClientCache = dheClientCache; @@ -123,6 +131,7 @@ export class DheService implements IDheService { private readonly _onWorkerRemoved = new vscode.EventEmitter(); readonly onWorkerRemoved = this._onWorkerRemoved.event; + readonly label: string; readonly serverUrl: URL; /** diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 10b9d5fec..9e2f59e8d 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -352,6 +352,7 @@ export class ServerManager implements IServerManager { } const connection = this._dhcServiceFactory.create( + workerInfo?.name ?? serverUrl.host, workerUrl, isOwned, workerInfo?.tagId @@ -496,6 +497,7 @@ export class ServerManager implements IServerManager { ): Promise => { const tagId = uniqueId(); const placeholderUrl = this.addWorkerPlaceholderConnection( + dheService.label, dheService.serverUrl, tagId ); @@ -597,11 +599,16 @@ export class ServerManager implements IServerManager { /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. + * @param label The connection label to show in the UI for this pending worker. * @param serverUrl The DHE server URL the pending worker is associated with. * @param tagId The tag ID of the worker. * @returns The placeholder URL. */ - addWorkerPlaceholderConnection = (serverUrl: URL, tagId: UniqueID): URL => { + addWorkerPlaceholderConnection = ( + label: string, + serverUrl: URL, + tagId: UniqueID + ): URL => { // simple way to keep placeholder urls unique by just adding a tagId as the pathname const placeholderUrl = new URL(serverUrl); placeholderUrl.pathname = tagId; @@ -609,6 +616,7 @@ export class ServerManager implements IServerManager { this._workerURLToServerURLMap.set(placeholderUrl, serverUrl); this._connectionMap.set(placeholderUrl, { + label, isConnected: false, isRunningCode: false, serverUrl: placeholderUrl, diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index 0c79e1cb7..a5b0dd32d 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -170,6 +170,7 @@ export interface WorkerConfig { export interface ConnectionState { readonly isConnected: boolean; readonly isRunningCode?: boolean; + readonly label: string; readonly serverUrl: URL; readonly tagId?: UniqueID; } diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 7bea8e58e..4fe0405a7 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -119,7 +119,7 @@ export type ICoreClientFactory = ( */ export type IDhcServiceFactory = IFactory< IDhcService, - [serverUrl: URL, isOwned: boolean, tagId?: UniqueID] + [label: string, serverUrl: URL, isOwned: boolean, tagId?: UniqueID] >; export type IDheClientFactory = ( serverUrl: URL diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 6552fa8b0..b294c0fc9 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from 'vitest'; import { bitValues, boolValues, matrix } from '../testUtils'; import { getConnectionServerTreeItem, - getConnectionWorkerLabel, getPanelConnectionTreeItem, getPanelVariableTreeItem, getServerContextValue, @@ -15,12 +14,10 @@ import { groupServers, } from './treeViewUtils'; import type { - ConnectionState, ConsoleType, IDhcService, Psk, ServerState, - UniqueID, VariableDefintion, VariableType, } from '../types'; @@ -103,51 +100,6 @@ describe('getConnectionServerTreeItem', () => { }); }); -describe('getConnectionWorkerLabel', () => { - const connection = { - isConnected: true, - serverUrl: new URL('http://localhost:10000'), - tagId: 'mock.tagId' as UniqueID, - } as ConnectionState; - - const dhcServer: ServerState = { - type: 'DHC', - url: new URL('http://localhost:10000'), - label: 'My DHC Server', - isConnected: true, - isRunning: true, - connectionCount: 1, - }; - - it('should prefer the persistent-query name when present (DHE worker)', () => { - expect( - getConnectionWorkerLabel(dhcServer, connection, 'Some Worker PQ') - ).toBe('Some Worker PQ'); - }); - - it('should fall back to the server label when there is no pq name (DHC)', () => { - expect(getConnectionWorkerLabel(dhcServer, connection, undefined)).toBe( - 'My DHC Server' - ); - }); - - it('should fall back to the server url host when the server has no label', () => { - expect( - getConnectionWorkerLabel( - { ...dhcServer, label: undefined }, - connection, - undefined - ) - ).toBe('localhost:10000'); - }); - - it('should fall back to the connection serverUrl host when no parent server resolves', () => { - expect(getConnectionWorkerLabel(undefined, connection, undefined)).toBe( - 'localhost:10000' - ); - }); -}); - describe('getPanelVariableTreeItem', () => { const url = new URL('http://localhost:10000'); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 5ea4e4083..13a6e5114 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -142,28 +142,6 @@ export function getConnectionServerLabel(server: ServerState): string { return server.label ?? server.url.host; } -/** - * Get the label shown for a worker (connection) node in the connection / panel - * tree views. DHE workers use their persistent-query name; DHC connections, - * which have none, fall back to their server's label so the single child - * mirrors its parent server node. - * @param parentServer The connection's parent server, if resolved. - * @param connection The connection. - * @param pqName The persistent-query name (DHE workers only). - */ -export function getConnectionWorkerLabel( - parentServer: ServerState | undefined, - connection: ConnectionState, - pqName: string | undefined -): string { - const serverLabel = - parentServer == null - ? connection.serverUrl.host - : getConnectionServerLabel(parentServer); - - return pqName ?? serverLabel ?? connection.tagId ?? ''; -} - /** * Compute the root nodes for the connection / panel tree views. Every * connection is grouped under its parent server node (DHC and DHE alike), so a diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 5645fc586..9cd19c404 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -33,7 +33,7 @@ import type { MultiAuthConfig, } from '../types'; import { getFilePathDateToken, sortByStringProp } from './dataUtils'; -import { getConnectionWorkerLabel, getConsoleTypeIconId } from './treeViewUtils'; +import { getConsoleTypeIconId } from './treeViewUtils'; import { Logger } from './Logger'; const logger = new Logger('uiUtils'); @@ -93,21 +93,12 @@ export async function createConnectionQuickPickOptions< editorActiveConnectionUrl?.toString() === dhService.serverUrl.toString(); - const parentServer = - serverManager.getServerForConnection(dhService); - const workerInfo = await serverManager.getWorkerInfo( - dhService.serverUrl - ); - + const parentServer = serverManager.getServerForConnection(dhService); const host = parentServer?.url.host ?? dhService.serverUrl.host; return { type: 'connection' as const, - label: getConnectionWorkerLabel( - parentServer, - dhService, - workerInfo?.name - ), + label: dhService.label, iconPath: new vscode.ThemeIcon( getConsoleTypeIconId(editorLanguageId as ConsoleType) ), From 2e3b69faa8711902b956288979ad9b22c7b52013 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 13:40:55 -0500 Subject: [PATCH 15/68] tweak labels for server + workers (#DH-22593) --- src/services/ServerManager.ts | 27 ++++++++++++++++++++++----- src/util/uiUtils.ts | 18 ++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 9e2f59e8d..6812d9215 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -289,7 +289,7 @@ export class ServerManager implements IServerManager { try { if (serverState.type === 'DHC') { // DHC attach to Core worker - firstConnection = await this._attachToWorker(serverUrl, true); + firstConnection = await this._attachToWorker('Core', serverUrl, true); this._resolvePendingServerConnection(serverUrl); } else { @@ -324,11 +324,13 @@ export class ServerManager implements IServerManager { * `_workerURLToServerURLMap` for auth lookup and `_attachedWorkerSerials` * for idempotency/teardown. Used by both the create path and the attach path. * Does NOT touch placeholder connections — that is the create path's concern. + * @param label The connection label to show in the UI for this worker. * @param serverUrl The DHE server this worker belongs to. * @param workerInfo Worker info built from the query. * @returns The new connection state, or null on failure. */ private _attachToWorker = async ( + label: string, serverUrl: URL, isOwned: boolean, workerInfo?: WorkerInfo @@ -352,7 +354,7 @@ export class ServerManager implements IServerManager { } const connection = this._dhcServiceFactory.create( - workerInfo?.name ?? serverUrl.host, + label, workerUrl, isOwned, workerInfo?.tagId @@ -479,7 +481,12 @@ export class ServerManager implements IServerManager { const connections = ( await Promise.all( workerInfos.map(([isOwned, workerInfo]) => - this._attachToWorker(dheService.serverUrl, isOwned, workerInfo) + this._attachToWorker( + workerInfo.name, + dheService.serverUrl, + isOwned, + workerInfo + ) ) ) ).filter(c => c != null); @@ -566,7 +573,12 @@ export class ServerManager implements IServerManager { return null; } - return this._attachToWorker(dheServerUrl, true, workerInfo); + return this._attachToWorker( + workerInfo.name, + dheServerUrl, + true, + workerInfo + ); }; /** @@ -582,7 +594,12 @@ export class ServerManager implements IServerManager { return; } const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker(dheServerUrl, false, workerInfo); + await this._attachToWorker( + workerInfo.name, + dheServerUrl, + false, + workerInfo + ); }; /** diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index 9cd19c404..f37e86b32 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -80,8 +80,8 @@ export async function createConnectionQuickPickOptions< const serverOptions: ConnectionPickItem<'server', ServerState>[] = servers.map(data => ({ type: 'server', - label: data.url.toString(), - description: data.label ?? (data.isManaged ? 'pip' : undefined), + label: data.label ?? data.url.toString(), + description: data.isManaged ? 'pip' : undefined, iconPath: new vscode.ThemeIcon(ICON_ID.server), data, })); @@ -94,7 +94,17 @@ export async function createConnectionQuickPickOptions< dhService.serverUrl.toString(); const parentServer = serverManager.getServerForConnection(dhService); - const host = parentServer?.url.host ?? dhService.serverUrl.host; + assertDefined(parentServer, 'parentServer'); + + const descriptionTokens = [ + parentServer.type === 'DHC' + ? (parentServer.label ?? parentServer.url.host) + : `${parentServer.label ?? parentServer.url.hostname}:${dhService.serverUrl.port}`, + ]; + + if (isActiveConnection) { + descriptionTokens.push('(current)'); + } return { type: 'connection' as const, @@ -102,7 +112,7 @@ export async function createConnectionQuickPickOptions< iconPath: new vscode.ThemeIcon( getConsoleTypeIconId(editorLanguageId as ConsoleType) ), - description: isActiveConnection ? `${host} (current)` : host, + description: descriptionTokens.join(' '), data: dhService, }; }) From af6d921405466d92143a22d22b82aeea5c84d540 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 13:51:01 -0500 Subject: [PATCH 16/68] simplified option description (#DH-22593) --- src/util/uiUtils.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/util/uiUtils.ts b/src/util/uiUtils.ts index f37e86b32..50ac53373 100644 --- a/src/util/uiUtils.ts +++ b/src/util/uiUtils.ts @@ -96,11 +96,7 @@ export async function createConnectionQuickPickOptions< const parentServer = serverManager.getServerForConnection(dhService); assertDefined(parentServer, 'parentServer'); - const descriptionTokens = [ - parentServer.type === 'DHC' - ? (parentServer.label ?? parentServer.url.host) - : `${parentServer.label ?? parentServer.url.hostname}:${dhService.serverUrl.port}`, - ]; + const descriptionTokens = [parentServer.label ?? parentServer.url.host]; if (isActiveConnection) { descriptionTokens.push('(current)'); From 7d55189544c30f9c052b6f2b42e097da894a2ce6 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 14:08:57 -0500 Subject: [PATCH 17/68] Removed create worker action from community server nodes (#DH-22593) --- src/util/treeViewUtils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 13a6e5114..7294736e1 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -196,7 +196,10 @@ export function getConnectionServerTreeItem( // nodes, before the language (Python/Groovy) icons took their place. iconPath: new vscode.ThemeIcon(ICON_ID.connected), collapsibleState: vscode.TreeItemCollapsibleState.Expanded, - contextValue: CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent, + contextValue: + server.type === 'DHE' + ? CONNECTION_TREE_ITEM_CONTEXT.isDHEServerConnectionParent + : undefined, }; } From f1f9c7443cc8bc7885b79775a1900e936f2a0d10 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 15:51:42 -0500 Subject: [PATCH 18/68] Fixed tests (#DH-22593) --- src/util/__snapshots__/uiUtils.spec.ts.snap | 12 ++++++++---- src/util/uiUtils.spec.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/util/__snapshots__/uiUtils.spec.ts.snap b/src/util/__snapshots__/uiUtils.spec.ts.snap index 9009195c7..7c067cc81 100644 --- a/src/util/__snapshots__/uiUtils.spec.ts.snap +++ b/src/util/__snapshots__/uiUtils.spec.ts.snap @@ -31,6 +31,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, "description": "localhost:10000 (current)", @@ -38,12 +39,13 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, "description": "localhost:10002", @@ -51,7 +53,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10002", + "label": "ServerC Connection", "type": "connection", }, { @@ -102,6 +104,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed { "data": { "isConnected": true, + "label": "ServerA Connection", "serverUrl": "http://localhost:10000/", }, "description": "localhost:10000", @@ -109,12 +112,13 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10000", + "label": "ServerA Connection", "type": "connection", }, { "data": { "isConnected": true, + "label": "ServerC Connection", "serverUrl": "http://localhost:10002/", }, "description": "localhost:10002", @@ -122,7 +126,7 @@ exports[`createConnectionQuickPickOptions > should return quick pick options: ed "color": undefined, "id": "dh-python", }, - "label": "localhost:10002", + "label": "ServerC Connection", "type": "connection", }, { diff --git a/src/util/uiUtils.spec.ts b/src/util/uiUtils.spec.ts index 575f53f5b..24e61b8a4 100644 --- a/src/util/uiUtils.spec.ts +++ b/src/util/uiUtils.spec.ts @@ -74,8 +74,16 @@ describe('createConnectionQuickPickOptions', () => { }, ]; const connections: ConnectionState[] = [ - { serverUrl: serverUrlA, isConnected: true }, - { serverUrl: serverUrlC, isConnected: true }, + { + label: 'ServerA Connection', + serverUrl: serverUrlA, + isConnected: true, + }, + { + label: 'ServerC Connection', + serverUrl: serverUrlC, + isConnected: true, + }, ]; const serverManager = { From cc47a153aff07604142ec33a6f3a89c2a842a0d3 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 16:12:26 -0500 Subject: [PATCH 19/68] Fixed tests (#DH-22593) --- src/services/ServerManager.spec.ts | 55 +++++++++++++----------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 46cc573b8..f4e10009e 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ServerManager } from './ServerManager'; -import { URLMap, withResolvers } from '../util'; +import { URLMap, withResolvers, type PromiseWithResolvers } from '../util'; import type { ConnectionState, IAsyncCacheService, @@ -36,17 +36,14 @@ vi.mock('../dh/dhe', () => ({ type TestServerManager = PublicOf & { _serverMap: URLMap; _connectionMap: URLMap; - _pendingConnectionMap: URLMap>; + _pendingConnectionMap: URLMap>; + _pendingServerConnections: URLMap>; _dhcServiceFactory: { create: ReturnType }; _dheServiceCache: { has: ReturnType; get: ReturnType; }; - _setPendingConnection: ( - serverUrl: URL, - connectionPromise: Promise - ) => void; - _clearPendingConnection: (serverUrl: URL) => void; + _resolvePendingServerConnection: (serverUrl: URL) => void; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -161,7 +158,7 @@ describe('ServerManager.connectToServer', () => { // The second call dedupes against the in-flight connection rather than // creating a new one. expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(1); - expect(manager.isConnecting(serverUrl)).toBe(true); + expect(manager.isServerConnecting(serverUrl)).toBe(true); resolveClient({}); @@ -184,11 +181,11 @@ describe('ServerManager.connectToServer', () => { // in-flight connection rather than starting a second one (multiple workers // are created later, off the single client connection). expect(manager._dheServiceCache.get).toHaveBeenCalledTimes(1); - expect(manager.isConnecting(serverUrl)).toBe(true); + expect(manager.isServerConnecting(serverUrl)).toBe(true); }); }); -describe('ServerManager.isConnecting', () => { +describe('ServerManager.isServerConnecting', () => { let manager: TestServerManager; beforeEach(() => { @@ -196,39 +193,35 @@ describe('ServerManager.isConnecting', () => { manager = createServerManager(); }); - it('toggles isConnecting and fires onDidUpdate on set/clear', () => { + it('reflects a pending server connection and fires onDidUpdate when it resolves', () => { const onDidUpdate = vi.fn(); manager.onDidUpdate(onDidUpdate); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); - manager._setPendingConnection(serverUrl, Promise.resolve(null)); - expect(manager.isConnecting(serverUrl)).toBe(true); - expect(onDidUpdate).toHaveBeenCalledTimes(1); + manager._pendingServerConnections.set(serverUrl, withResolvers()); + expect(manager.isServerConnecting(serverUrl)).toBe(true); - manager._clearPendingConnection(serverUrl); - expect(manager.isConnecting(serverUrl)).toBe(false); - expect(onDidUpdate).toHaveBeenCalledTimes(2); + manager._resolvePendingServerConnection(serverUrl); + expect(manager.isServerConnecting(serverUrl)).toBe(false); + expect(onDidUpdate).toHaveBeenCalledTimes(1); }); - it('clears idempotently and does not fire when there is no pending entry', () => { + it('resolves idempotently and does not fire when there is no pending entry', () => { const onDidUpdate = vi.fn(); manager.onDidUpdate(onDidUpdate); - // Clearing when not connecting is a no-op (no fire). - manager._clearPendingConnection(serverUrl); + // Resolving when not connecting is a no-op (no fire). + manager._resolvePendingServerConnection(serverUrl); expect(onDidUpdate).not.toHaveBeenCalled(); - manager._setPendingConnection(serverUrl, Promise.resolve(null)); + manager._pendingServerConnections.set(serverUrl, withResolvers()); + manager._resolvePendingServerConnection(serverUrl); expect(onDidUpdate).toHaveBeenCalledTimes(1); - manager._clearPendingConnection(serverUrl); - expect(onDidUpdate).toHaveBeenCalledTimes(2); - - // Clearing again is a no-op (no extra fire) — covers DHE's early clear - // followed by the connect-settle clear. - manager._clearPendingConnection(serverUrl); - expect(onDidUpdate).toHaveBeenCalledTimes(2); + // Resolving again is a no-op (no extra fire). + manager._resolvePendingServerConnection(serverUrl); + expect(onDidUpdate).toHaveBeenCalledTimes(1); }); it('clears the pending entry when a connect settles, so a retry is not blocked', async () => { @@ -248,12 +241,12 @@ describe('ServerManager.isConnecting', () => { .mockReturnValueOnce(okConnection); expect(await manager.connectToServer(serverUrl)).toBeNull(); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); // The retry is not blocked by a stale pending entry — it starts a new // connection rather than deduping against the failed one. expect(await manager.connectToServer(serverUrl)).toBe(okConnection); expect(manager._dhcServiceFactory.create).toHaveBeenCalledTimes(2); - expect(manager.isConnecting(serverUrl)).toBe(false); + expect(manager.isServerConnecting(serverUrl)).toBe(false); }); }); From a12fc365512db7d8119772420502c6452701e87d Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 16:12:59 -0500 Subject: [PATCH 20/68] Fixed type error (#DH-22593) --- src/services/ServerManager.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index f4e10009e..3205f41d0 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -77,7 +77,7 @@ function createServerManager(): TestServerManager { } function mockConnectionState(url: URL): ConnectionState { - return { isConnected: true, serverUrl: url }; + return { label: 'Mock connection state', isConnected: true, serverUrl: url }; } function mockServerState({ From ef5111e53ad50a5fe4e05d788c6f5b326e48ab8c Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 18 Jun 2026 17:01:02 -0500 Subject: [PATCH 21/68] VS Code settings (#DH-22593) --- .vscode/settings.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index cebd853da..6b687d9c3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,10 @@ "out": true // set this to false to include "out" folder in search results }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", - "typescript.tsdk": "node_modules/typescript/lib" + "js/ts.tsc.autoDetect": "off", + // Unit tests are excluded from the primary tsconfig.json used by VS Code + // editor. Setting the implicit project target to match the tsconfig.json so + // that the editor shows proper language features for the test files. + "js/ts.implicitProjectConfig.target": "ES2023", + "js/ts.tsdk.path": "node_modules/typescript/lib" } From 92befcd332ffc1d0767b854ba2d6d6bd6aa883e9 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 11:57:29 -0500 Subject: [PATCH 22/68] Improved error handling + re-use util (#DH-22593) --- src/dh/dhe.ts | 2 +- src/services/DheService.ts | 38 +++++++++++++++++++++-------------- src/services/ServerManager.ts | 34 +++++++++++++++++++++---------- src/util/treeViewUtils.ts | 7 ++----- 4 files changed, 49 insertions(+), 32 deletions(-) diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 8ee20f487..3f683e929 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -220,7 +220,7 @@ export async function loginClientWrapper( * @param consoleType The type of console to create. * @returns A promise that resolves to the serial of the created query. Note * that this will resolve before the query is actually ready to use. Use - * `getWorkerInfoFromQuery` to get the worker info when the query is ready. + * `getWorkerInfoFromQuerySerial` to get the worker info when the query is ready. */ export async function createInteractiveConsoleQuery( tagId: UniqueID, diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 6e8c25029..300e862d7 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -265,24 +265,32 @@ export class DheService implements IDheService { this._onWorkerRemoved.fire(queryInfo.serial as QuerySerial); }; - const removeAdded = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_ADDED, - onConfigAddedOrUpdated + const removeConfigListeners: (() => void)[] = []; + this._removeConfigListeners = (): void => { + for (const remove of removeConfigListeners) { + remove(); + } + removeConfigListeners.length = 0; + }; + + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_ADDED, + onConfigAddedOrUpdated + ) ); - const removeUpdated = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_UPDATED, - onConfigAddedOrUpdated + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_UPDATED, + onConfigAddedOrUpdated + ) ); - const removeRemoved = dheClient.client.addEventListener( - dhe.Client.EVENT_CONFIG_REMOVED, - onConfigRemoved + removeConfigListeners.push( + dheClient.client.addEventListener( + dhe.Client.EVENT_CONFIG_REMOVED, + onConfigRemoved + ) ); - - this._removeConfigListeners = (): void => { - removeAdded(); - removeUpdated(); - removeRemoved(); - }; }; /** diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 6812d9215..093ea2f36 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -471,10 +471,18 @@ export class ServerManager implements IServerManager { } // register attachable workers else { - workerInfos = attachableWorkers.map(queryInfo => [ - false, - dheService.registerWorkerInfo(queryInfo), - ]); + workerInfos = []; + for (const queryInfo of attachableWorkers) { + try { + workerInfos.push([false, dheService.registerWorkerInfo(queryInfo)]); + } catch (err) { + logger.error( + 'Failed to register attachable worker; skipping:', + queryInfo.serial, + err + ); + } + } } // Connect to workers in parallel @@ -593,13 +601,17 @@ export class ServerManager implements IServerManager { if (this._attachedWorkerSerials.has(queryInfo.serial as QuerySerial)) { return; } - const workerInfo = dheService.registerWorkerInfo(queryInfo); - await this._attachToWorker( - workerInfo.name, - dheServerUrl, - false, - workerInfo - ); + try { + const workerInfo = dheService.registerWorkerInfo(queryInfo); + await this._attachToWorker( + workerInfo.name, + dheServerUrl, + false, + workerInfo + ); + } catch (err) { + logger.error('Failed to attach worker:', queryInfo.serial, err); + } }; /** diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 7294736e1..36dffc4b8 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -191,7 +191,7 @@ export function getConnectionServerTreeItem( server: ServerState ): vscode.TreeItem { return { - label: server.label ?? server.url.host, + label: getConnectionServerLabel(server), // The "computer" icon (`vm-connect`) previously used for worker connection // nodes, before the language (Python/Groovy) icons took their place. iconPath: new vscode.ThemeIcon(ICON_ID.connected), @@ -377,16 +377,13 @@ export function getServerTreeItem( const description = getServerDescription(connectionCount, isManaged); - const urlStr = server.url.toString(); - const canConnect = contextValue === SERVER_TREE_ITEM_CONTEXT.isManagedServerDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isServerRunningDisconnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningConnected || contextValue === SERVER_TREE_ITEM_CONTEXT.isDHEServerRunningDisconnected; - const url = new URL(urlStr); - const label = server.label ?? url.host; + const label = getConnectionServerLabel(server); return { label, From b285918dbd532b416d2ad4dadb0e4bcb2efddd7a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 12:05:56 -0500 Subject: [PATCH 23/68] Added label to mcp connection data (#DH-22593) --- src/mcp/tools/listConnections.spec.ts | 2 ++ src/mcp/tools/listConnections.ts | 4 +++- src/mcp/utils/serverUtils.spec.ts | 3 +++ src/mcp/utils/serverUtils.ts | 3 +++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/mcp/tools/listConnections.spec.ts b/src/mcp/tools/listConnections.spec.ts index 69b4fd704..5a8bcbab1 100644 --- a/src/mcp/tools/listConnections.spec.ts +++ b/src/mcp/tools/listConnections.spec.ts @@ -18,6 +18,7 @@ vi.mock('vscode'); const MOCK_CONNECTION_1: ConnectionState = { serverUrl: MOCK_DHC_URL, + label: 'Connection 1', isConnected: true, isRunningCode: false, tagId: 'conn1' as UniqueID, @@ -25,6 +26,7 @@ const MOCK_CONNECTION_1: ConnectionState = { const MOCK_CONNECTION_2: ConnectionState = { serverUrl: new URL('http://localhost:10001'), + label: 'Connection 2', isConnected: true, isRunningCode: true, tagId: 'conn2' as UniqueID, diff --git a/src/mcp/tools/listConnections.ts b/src/mcp/tools/listConnections.ts index 734d4890b..2c471359d 100644 --- a/src/mcp/tools/listConnections.ts +++ b/src/mcp/tools/listConnections.ts @@ -26,6 +26,7 @@ const spec = { .array( z.object({ serverUrl: z.string(), + label: z.string(), isConnected: z.boolean(), isRunningCode: z.boolean().optional(), querySerial: z.string().optional(), @@ -66,7 +67,7 @@ export function createListConnectionsTool({ const connections = await Promise.all( rawConnections.map( - async ({ serverUrl, isConnected, isRunningCode, tagId }) => { + async ({ serverUrl, label, isConnected, isRunningCode, tagId }) => { // Get worker info to retrieve querySerial for DHE connections const workerInfo = await serverManager.getWorkerInfo( serverUrl as WorkerURL @@ -74,6 +75,7 @@ export function createListConnectionsTool({ return { serverUrl: serverUrl.toString(), + label, isConnected, isRunningCode, tagId, diff --git a/src/mcp/utils/serverUtils.spec.ts b/src/mcp/utils/serverUtils.spec.ts index b63df4b8b..64f5a20c5 100644 --- a/src/mcp/utils/serverUtils.spec.ts +++ b/src/mcp/utils/serverUtils.spec.ts @@ -42,11 +42,13 @@ describe('serverUtils', () => { isRunningCode, serverUrl, tagId, + label: 'mock connection', }); expect(resultWithoutTag, 'without tag').toEqual({ isConnected, isRunningCode, + label: 'mock connection', serverUrl: serverUrl.toString(), tagId, }); @@ -389,6 +391,7 @@ describe('serverUtils', () => { const MOCK_CONNECTION: ConnectionState = { isConnected: true, isRunningCode: true, + label: 'mock connection', serverUrl, tagId, } as ConnectionState; diff --git a/src/mcp/utils/serverUtils.ts b/src/mcp/utils/serverUtils.ts index 26e5561ac..8e060d876 100644 --- a/src/mcp/utils/serverUtils.ts +++ b/src/mcp/utils/serverUtils.ts @@ -14,6 +14,7 @@ import { createConnectionNotFoundHint } from './runCodeUtils'; export const connectionResultSchema = z.object({ isConnected: z.boolean(), isRunningCode: z.boolean().optional(), + label: z.string(), serverUrl: z.string(), tagId: z.string().optional(), }); @@ -57,12 +58,14 @@ export type GetFirstConnectionOrCreateResult = export function connectionToResult({ isConnected, isRunningCode, + label, serverUrl, tagId, }: ConnectionState): ConnectionResult { return { isConnected, isRunningCode, + label, serverUrl: serverUrl.toString(), tagId, }; From dc8b70f070d15124c4e08959d61640458ccc6e7b Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 22 Jun 2026 13:50:41 -0500 Subject: [PATCH 24/68] Rename "Connections" -> "Workers" in e2e tests (#DH-22593) --- e2e-testing/src/specs/statusBar.spec.ts | 2 +- e2e-testing/src/util/constants.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e-testing/src/specs/statusBar.spec.ts b/e2e-testing/src/specs/statusBar.spec.ts index 8359892b1..89fafa794 100644 --- a/e2e-testing/src/specs/statusBar.spec.ts +++ b/e2e-testing/src/specs/statusBar.spec.ts @@ -90,7 +90,7 @@ describe('Status Bar Tests', () => { await step(4, 'Verify connection node', async stepLabel => { const simpleTickingEditor = await getSidebarViewItem( - VIEW_NAME.connections, + VIEW_NAME.workers, SIMPLE_TICKING3_PY.name ); diff --git a/e2e-testing/src/util/constants.ts b/e2e-testing/src/util/constants.ts index 53af58fd4..1c06f3960 100644 --- a/e2e-testing/src/util/constants.ts +++ b/e2e-testing/src/util/constants.ts @@ -15,6 +15,6 @@ export const RETRY_SWITCH_IFRAME_ERRORS: ReadonlySet = new Set([ export const VIEW_NAME = { servers: 'Servers', - connections: 'Connections', + workers: 'Workers', panels: 'Panels', } as const; From dfdca4db11284f967169842139ea3d13af7a71bd Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 27 Jul 2026 13:49:47 -0500 Subject: [PATCH 25/68] Bumped dh packages (#DH-22593) --- package-lock.json | 221 ++++++++++++++++++++++++---------------------- package.json | 6 +- 2 files changed, 119 insertions(+), 108 deletions(-) diff --git a/package-lock.json b/package-lock.json index a013b203e..5997675fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "packages/*" ], "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^2026.1.38", - "@deephaven-enterprise/query-utils": "^2026.1.38", + "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/query-utils": "^2026.1.48", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -23,7 +23,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^2026.1.38", + "@deephaven-enterprise/jsapi-types": "^2026.1.48", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", @@ -556,56 +556,69 @@ } }, "node_modules/@deephaven-enterprise/auth-nodejs": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", - "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.48.tgz", + "integrity": "sha512-IppinkV/ewW3FYcd2vLFAgUnll5XsVNuZaSvtg0sgKfPz99axRtdLmB6og3V2BmuPj//QO/PeUFVJzutotOZeQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", "@deephaven/utils": "^0.97.0" } }, - "node_modules/@deephaven-enterprise/auth-nodejs/node_modules/@deephaven-enterprise/jsapi-types": { - "resolved": "node_modules/@deephaven-enterprise/jsapi-types", - "link": true + "node_modules/@deephaven-enterprise/client-utils": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/client-utils/-/client-utils-2026.1.48.tgz", + "integrity": "sha512-LaWCwDmtA7ofTqURojYE5hWvdrUuOweN21stVjSBVTm5zo7WPq1Q4Vadpw8LUrhtXSWBnWwaqDG4kK9+Km+POA==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0" + } + }, + "node_modules/@deephaven-enterprise/client-utils/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/jsapi-types": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", - "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.48.tgz", + "integrity": "sha512-REWSmpsPrVEaeezm+W+sTmjKeK0h3leIbhtnGzhfXZQFOsisBMVDNdXApE/CUqEkUcqGNnEx58tsKI2w1kYfqA==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + "@deephaven/jsapi-types": "1.0.0-dev0.40.5" } }, "node_modules/@deephaven-enterprise/jsapi-types/node_modules/@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==", + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", "license": "Apache-2.0" }, "node_modules/@deephaven-enterprise/query-utils": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", - "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.48.tgz", + "integrity": "sha512-P3gggQQxHZa12iQ1Atjm7gJ+23p74J7YG/V8cnIAef33nYWuWm8XgJsXF/kSh6ugAP8RUZ10QEDXMhyOx2yIOQ==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", - "@internationalized/date": "^3.5.5", - "nanoid": "^5.1.6" + "@internationalized/date": "^3.12.2", + "nanoid": "^5.1.6", + "shell-quote": "^1.8.4" } }, - "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven-enterprise/jsapi-types": { - "resolved": "node_modules/@deephaven-enterprise/jsapi-types", - "link": true - }, "node_modules/@deephaven-enterprise/query-utils/node_modules/@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==", + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", "license": "Apache-2.0" }, "node_modules/@deephaven/filters": { @@ -1369,9 +1382,10 @@ "license": "BSD-3-Clause" }, "node_modules/@internationalized/date": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", - "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } @@ -2302,11 +2316,12 @@ "license": "MIT" }, "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", "dependencies": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "node_modules/@textlint/ast-node-types": { @@ -11731,10 +11746,13 @@ } }, "node_modules/shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12983,9 +13001,10 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" }, "node_modules/ttf2eot": { "version": "3.1.0", @@ -15005,74 +15024,67 @@ "dev": true }, "@deephaven-enterprise/auth-nodejs": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.38.tgz", - "integrity": "sha512-eMSlC52lQSnuPIiarHKBv7Yu5lufgKYgHXlrFWxAR+1UJIGf7SgtiW44X6ijsY1bSPrJAjgbaLUnD3j6DtlX2g==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/auth-nodejs/-/auth-nodejs-2026.1.48.tgz", + "integrity": "sha512-IppinkV/ewW3FYcd2vLFAgUnll5XsVNuZaSvtg0sgKfPz99axRtdLmB6og3V2BmuPj//QO/PeUFVJzutotOZeQ==", "requires": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven/utils": "^0.97.0" + } + }, + "@deephaven-enterprise/client-utils": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/client-utils/-/client-utils-2026.1.48.tgz", + "integrity": "sha512-LaWCwDmtA7ofTqURojYE5hWvdrUuOweN21stVjSBVTm5zo7WPq1Q4Vadpw8LUrhtXSWBnWwaqDG4kK9+Km+POA==", + "requires": { + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0" }, "dependencies": { - "@deephaven-enterprise/jsapi-types": { - "version": "file:node_modules/@deephaven-enterprise/jsapi-types", - "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" - }, - "dependencies": { - "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" - } - } + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, "@deephaven-enterprise/jsapi-types": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.38.tgz", - "integrity": "sha512-k+VEeI0QSF3eCqpc+N3LDNiC1WBfJ7yVEz1eLaFzHerYY7JwRKmJe6WmPjFn6CuMozqE/yhKAor3fFyPu5NltQ==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.48.tgz", + "integrity": "sha512-REWSmpsPrVEaeezm+W+sTmjKeK0h3leIbhtnGzhfXZQFOsisBMVDNdXApE/CUqEkUcqGNnEx58tsKI2w1kYfqA==", "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" + "@deephaven/jsapi-types": "1.0.0-dev0.40.5" }, "dependencies": { "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, "@deephaven-enterprise/query-utils": { - "version": "2026.1.38", - "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.38.tgz", - "integrity": "sha512-9BOb/0mJP/kst72aL4jmsRmidR+Z9gXkihxnfILdWLU/D/Go+KRZ5ObYVHPiO2ntiJ1ymYxUFie6TMNpb31WyQ==", + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/query-utils/-/query-utils-2026.1.48.tgz", + "integrity": "sha512-P3gggQQxHZa12iQ1Atjm7gJ+23p74J7YG/V8cnIAef33nYWuWm8XgJsXF/kSh6ugAP8RUZ10QEDXMhyOx2yIOQ==", "requires": { - "@deephaven-enterprise/jsapi-types": "file:../jsapi-types", - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", "@deephaven/log": "^0.97.0", "@deephaven/utils": "^0.97.0", - "@internationalized/date": "^3.5.5", - "nanoid": "^5.1.6" + "@internationalized/date": "^3.12.2", + "nanoid": "^5.1.6", + "shell-quote": "^1.8.4" }, "dependencies": { - "@deephaven-enterprise/jsapi-types": { - "version": "file:node_modules/@deephaven-enterprise/jsapi-types", - "requires": { - "@deephaven/jsapi-types": "^1.0.0-dev0.36.1" - }, - "dependencies": { - "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" - } - } - }, "@deephaven/jsapi-types": { - "version": "1.0.0-dev0.40.9", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.9.tgz", - "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" } } }, @@ -15499,9 +15511,9 @@ "dev": true }, "@internationalized/date": { - "version": "3.5.5", - "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.5.5.tgz", - "integrity": "sha512-H+CfYvOZ0LTJeeLOqm19E3uj/4YjrmOFtBufDHPfvtI80hFAMqtrp7oCACpe4Cil5l8S0Qu/9dYfZc/5lY8WQQ==", + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", "requires": { "@swc/helpers": "^0.5.0" } @@ -16099,11 +16111,11 @@ "dev": true }, "@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "requires": { - "tslib": "^2.4.0" + "tslib": "^2.8.0" } }, "@textlint/ast-node-types": { @@ -22687,10 +22699,9 @@ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, "shell-quote": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", - "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", - "dev": true + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==" }, "side-channel": { "version": "1.1.0", @@ -23578,9 +23589,9 @@ } }, "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, "ttf2eot": { "version": "3.1.0", diff --git a/package.json b/package.json index 7ff79d550..9257dda98 100644 --- a/package.json +++ b/package.json @@ -1154,8 +1154,8 @@ } }, "dependencies": { - "@deephaven-enterprise/auth-nodejs": "^2026.1.38", - "@deephaven-enterprise/query-utils": "^2026.1.38", + "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/query-utils": "^2026.1.48", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", @@ -1166,7 +1166,7 @@ }, "devDependencies": { "@cfworker/json-schema": "^4.1.1", - "@deephaven-enterprise/jsapi-types": "^2026.1.38", + "@deephaven-enterprise/jsapi-types": "^2026.1.48", "@deephaven/jsapi-types": "^41.2.0", "@react-types/shared": "^3.33.1", "@types/archiver": "^6.0.3", From 7ec75ee0cedef662a5fb70eed765395e832e964e Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 27 Jul 2026 18:00:24 -0500 Subject: [PATCH 26/68] Add shared CorePlusManager substrate + QueryInfo table service (#DH-22593) Introduce one EnterpriseCorePlusManager per DHE server, built from the extension's already-authenticated client (no second login), and a reusable ticking QueryInfo table service for the upcoming PQ explorer. - DheService: lazy getCorePlusManager() built via createJsApiFactories hooks (loadCorePlusApi + construct-only createCoreClient) using the existing DHE client, jsapi storage dir, and NodeHttp2gRPCTransport. Disposed with the service and reset on client-cache invalidation (manager is bound to the client instance). Added to the IDheService interface. - QueryConfigTableService: getQueryInfoTable(filters) backed by makeFactoryServiceTablePromise (QUERY_CONFIG_TABLE) with a server-side filtered, ticking viewport subscription; pure getQueryTableFilters helper (owner/type/status/search) unit-tested against a mocked table; clear WebClientDataUnavailableError when the WebClientData system query is absent. - Attach discovery is unchanged (stays on config events). - dhe.ts: declare the ambient `iris` global as `var` so it merges with the jsapi-nodejs ambient declaration instead of conflicting. - Add @deephaven-enterprise/{jsapi-nodejs,jsapi-manager,client-utils} deps at 2026.1.48 (matching the installed @deephaven-enterprise/* packages). Constructor verified against the installed EnterpriseCorePlusManager .d.ts: (dhe, dheClient, workerKinds, loadCorePlusApi, createCoreClient). Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- docs/manual-smoke-coreplus-manager.md | 51 ++++ package-lock.json | 89 +++++++ package.json | 3 + src/dh/dhe.ts | 8 +- src/services/DheService.ts | 109 +++++++- src/services/QueryConfigTableService.spec.ts | 186 +++++++++++++ src/services/QueryConfigTableService.ts | 267 +++++++++++++++++++ src/types/serviceTypes.d.ts | 2 + 8 files changed, 711 insertions(+), 4 deletions(-) create mode 100644 docs/manual-smoke-coreplus-manager.md create mode 100644 src/services/QueryConfigTableService.spec.ts create mode 100644 src/services/QueryConfigTableService.ts diff --git a/docs/manual-smoke-coreplus-manager.md b/docs/manual-smoke-coreplus-manager.md new file mode 100644 index 000000000..1346cfa51 --- /dev/null +++ b/docs/manual-smoke-coreplus-manager.md @@ -0,0 +1,51 @@ +# Manual Smoke — Shared CorePlusManager Substrate + +These checks verify the `EnterpriseCorePlusManager` substrate added to +`DheService` (Phase 1) and the `QueryConfigTableService` (Phase 2). They require +a live Core+ (Deephaven Enterprise) server with the always-on `WebClientData` +system query running, and **cannot** be run in the devcontainer (the extension +UI does not run there). Run them from a full VS Code instance connected to a +real server. + +The automated gates (`npm run build:ts`, `npm run test:unit`, +`npm run test:lint`) are the developer bar; the items below are the +requester-run verification. + +## (a) Attach-to-workers flow is unchanged (regression) + +Worker-attach discovery still runs on the DHE config-event stream and is **not** +routed through the manager or the `QueryInfo` table (Decision 3). Re-run the +existing attach behavior with the manager now present in-tree and confirm each +case is unchanged: + +- [ ] **Create-on-empty** — connect to a DHE server with no running IC workers; + exactly one worker is created and attached. +- [ ] **Auto-attach-on-external-create** — with the server connected, create a + running IC worker from another client (e.g. the web UI). It is auto-attached + in the WORKERS tree within a few seconds. +- [ ] **No-delete-on-detach** — detach an *attached* (not extension-created) + worker; the server-side PQ keeps running. +- [ ] **Delete-owned-on-disconnect** — disconnect the DHE server; workers the + extension created are deleted, attached workers are left running. + +## (b) Manager is disposed on server disconnect + +- [ ] Disconnect the DHE server (or log out). Confirm the `CorePlusManager` is + disposed: no residual Core+ worker connections remain open, and no + post-dispose config-event handling / log spam occurs from the manager. +- [ ] Reconnect to the same server. A **new** manager is built from the fresh + client on next use (e.g. opening the PQ table), and only **one** + authenticated DHE session exists per server (verify via server-side session + count / logs — no second login is triggered by manager creation). + +## (c) QueryInfo table service returns and ticks + +Using a scratch consumer of `QueryConfigTableService.getQueryInfoTable(...)` +(or the PQ explorer once built): + +- [ ] The service returns a filtered, ticking `QueryInfo` table subscription. +- [ ] A PQ status change made from another client (e.g. stopping/starting a + query) is reflected in the subscription's `onDidUpdate` snapshots within + ~5 seconds. +- [ ] If `WebClientData` is stopped / not visible to the user, the service + throws a clear `WebClientDataUnavailableError` instead of hanging (Gotcha 7). diff --git a/package-lock.json b/package-lock.json index 5997675fe..d5f6684a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ ], "dependencies": { "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/client-utils": "^2026.1.48", + "@deephaven-enterprise/jsapi-manager": "^2026.1.48", + "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", "@deephaven-enterprise/query-utils": "^2026.1.48", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", @@ -584,6 +587,49 @@ "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", "license": "Apache-2.0" }, + "node_modules/@deephaven-enterprise/jsapi-manager": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-manager/-/jsapi-manager-2026.1.48.tgz", + "integrity": "sha512-wZngbirWGFZJ6HQE4pwo9f7PY7gnY6ze17eBOcvIw8UikBKrm6nSUmZ3nctaOJVZxrPYlmuK4xHZKupVeuethQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven-enterprise/query-utils": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", + "@internationalized/date": "^3.12.2" + } + }, + "node_modules/@deephaven-enterprise/jsapi-manager/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" + }, + "node_modules/@deephaven-enterprise/jsapi-nodejs": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-nodejs/-/jsapi-nodejs-2026.1.48.tgz", + "integrity": "sha512-NUz4wCswJnrdCbH4fUFBSVxToOqL33PBw+Wn2HLovlazDRRFYsS22MAN0ypONsEtrG+//pCU8itHoRQfTWR4Hg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@deephaven-enterprise/auth-nodejs": "2026.1.48", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-manager": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven-enterprise/query-utils": "2026.1.48", + "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/utils": "^0.97.0" + } + }, + "node_modules/@deephaven-enterprise/jsapi-nodejs/node_modules/@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==", + "license": "Apache-2.0" + }, "node_modules/@deephaven-enterprise/jsapi-types": { "version": "2026.1.48", "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.48.tgz", @@ -15051,6 +15097,49 @@ } } }, + "@deephaven-enterprise/jsapi-manager": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-manager/-/jsapi-manager-2026.1.48.tgz", + "integrity": "sha512-wZngbirWGFZJ6HQE4pwo9f7PY7gnY6ze17eBOcvIw8UikBKrm6nSUmZ3nctaOJVZxrPYlmuK4xHZKupVeuethQ==", + "requires": { + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven-enterprise/query-utils": "2026.1.48", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/log": "^0.97.0", + "@deephaven/utils": "^0.97.0", + "@internationalized/date": "^3.12.2" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" + } + } + }, + "@deephaven-enterprise/jsapi-nodejs": { + "version": "2026.1.48", + "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-nodejs/-/jsapi-nodejs-2026.1.48.tgz", + "integrity": "sha512-NUz4wCswJnrdCbH4fUFBSVxToOqL33PBw+Wn2HLovlazDRRFYsS22MAN0ypONsEtrG+//pCU8itHoRQfTWR4Hg==", + "requires": { + "@deephaven-enterprise/auth-nodejs": "2026.1.48", + "@deephaven-enterprise/client-utils": "2026.1.48", + "@deephaven-enterprise/jsapi-manager": "2026.1.48", + "@deephaven-enterprise/jsapi-types": "2026.1.48", + "@deephaven-enterprise/query-utils": "2026.1.48", + "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-types": "1.0.0-dev0.40.5", + "@deephaven/utils": "^0.97.0" + }, + "dependencies": { + "@deephaven/jsapi-types": { + "version": "1.0.0-dev0.40.5", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-types/-/jsapi-types-1.0.0-dev0.40.5.tgz", + "integrity": "sha512-ysRBewjXwt/Agops1edt9olw9oY2ShL8ySE1fyqNBCnL6iwQ1KIFmN2FZVnu8QGChJG106jGvm7w3wA7YIwiXg==" + } + } + }, "@deephaven-enterprise/jsapi-types": { "version": "2026.1.48", "resolved": "https://registry.npmjs.org/@deephaven-enterprise/jsapi-types/-/jsapi-types-2026.1.48.tgz", diff --git a/package.json b/package.json index 9257dda98..49bc9255d 100644 --- a/package.json +++ b/package.json @@ -1155,6 +1155,9 @@ }, "dependencies": { "@deephaven-enterprise/auth-nodejs": "^2026.1.48", + "@deephaven-enterprise/client-utils": "^2026.1.48", + "@deephaven-enterprise/jsapi-manager": "^2026.1.48", + "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", "@deephaven-enterprise/query-utils": "^2026.1.48", "@deephaven/jsapi-nodejs": "^1.15.0", "@deephaven/jsapi-utils": "^1.16.0", diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 3f683e929..53f7f1981 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -50,9 +50,11 @@ export type IDraftQuery = EditableQueryInfo & { }; declare global { - // This gets added by the DHE jsapi. - // eslint-disable-next-line no-unused-vars - const iris: DheType; + // This gets added by the DHE jsapi. Declared as `var` (not `const`) so it + // merges with the ambient `var iris` global declared by + // `@deephaven-enterprise/jsapi-nodejs` rather than conflicting with it. + // eslint-disable-next-line no-unused-vars, no-var + var iris: DheType; } /** diff --git a/src/services/DheService.ts b/src/services/DheService.ts index 300e862d7..f6dcdbc81 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -3,6 +3,13 @@ import type { EnterpriseDhType as DheType, QueryInfo, } from '@deephaven-enterprise/jsapi-types'; +import type { + AuthenticatedEnterpriseClient, + CorePlusManager, +} from '@deephaven-enterprise/client-utils'; +import { EnterpriseCorePlusManager } from '@deephaven-enterprise/jsapi-manager'; +import { createJsApiFactories } from '@deephaven-enterprise/jsapi-nodejs'; +import { NodeHttp2gRPCTransport } from '@deephaven/jsapi-nodejs'; import { type ConsoleType, type DheAuthenticatedClientWrapper, @@ -18,7 +25,13 @@ import { type WorkerInfo, type WorkerURL, } from '../types'; -import { Logger, uniqueId, URLMap } from '../util'; +import { + getTempDir, + Logger, + uniqueId, + URLMap, + urlToDirectoryName, +} from '../util'; import { getWorkerInfoFromQueryInfo, createInteractiveConsoleQuery, @@ -111,6 +124,7 @@ export class DheService implements IDheService { private _clientPromise: Promise | null = null; + private _corePlusManagerPromise: Promise | null = null; private _isConnected: boolean = false; private _operateAs: string | null = null; private _removeConfigListeners: (() => void) | null = null; @@ -213,7 +227,31 @@ export class DheService implements IDheService { // Reset the client promise so that the next call to `getClient` can // reinitialize it if necessary. this._clientPromise = null; + + // The CorePlusManager is bound to the specific (now stale) client + // instance, so dispose + null it. The next `getCorePlusManager` call will + // rebuild one from the fresh client. + this._disposeCorePlusManager(); + } + }; + + /** + * Dispose the current CorePlusManager (if any) and reset the cached promise so + * a subsequent `getCorePlusManager` rebuilds one from the current client. + */ + private _disposeCorePlusManager = (): void => { + const managerPromise = this._corePlusManagerPromise; + this._corePlusManagerPromise = null; + + if (managerPromise == null) { + return; } + + void managerPromise + .then(manager => manager?.dispose()) + .catch(err => { + logger.error('Failed to dispose CorePlusManager', err); + }); }; /** @@ -348,6 +386,68 @@ export class DheService implements IDheService { return dheClient; } + /** + * Lazily create the `EnterpriseCorePlusManager` for this DHE server, built + * from the client this service already authenticates (Decision 1 — no second + * login). Returns null when no client is available. The manager is cached on + * the instance and disposed with the service / on client-cache invalidation. + * @returns The CorePlusManager or null if the client is not available. + */ + getCorePlusManager = async (): Promise => { + if (this._corePlusManagerPromise == null) { + this._corePlusManagerPromise = this._initCorePlusManager(); + } + + const manager = await this._corePlusManagerPromise; + + // If construction failed, clear the cached rejection/null so a later call + // can retry once the client is available. + if (manager == null) { + this._corePlusManagerPromise = null; + } + + return manager; + }; + + /** + * Build the `EnterpriseCorePlusManager` from the already-authenticated DHE + * client (Decision 1). Replicates `initCorePlusManager`'s assembly minus the + * login so the manager reuses the extension's existing session. Reuses the + * extension's jsapi storage dir (Gotcha 6) and `NodeHttp2gRPCTransport`. + * @returns The CorePlusManager or null if the client is not available. + */ + private _initCorePlusManager = async (): Promise => { + const dheClient = await this.getClient(false); + if (dheClient == null) { + return null; + } + + const dhe = await this._dheJsApiCache.get(this.serverUrl); + const { workerKinds } = await dheClient.client.getServerConfigValues(); + + // Only the `loadCorePlusApi` (worker api loader) and `createCoreClient` + // (construct-only, un-logged-in — Gotcha 2) hooks are needed. The manager + // owns worker login via an auth token off the DHE client. + const { loadCorePlusApi, createCoreClient } = createJsApiFactories({ + storageDir: getTempDir({ + subDirectory: urlToDirectoryName(this.serverUrl), + }), + transportFactory: NodeHttp2gRPCTransport.factory, + }); + + return new EnterpriseCorePlusManager( + dhe, + // `DheAuthenticatedClientWrapper.client` is branded + // `AuthenticatedClient` (auth-nodejs); the manager expects the + // structurally-identical `AuthenticatedEnterpriseClient` brand + // (client-utils). Same underlying `EnterpriseClient`. + dheClient.client as unknown as AuthenticatedEnterpriseClient, + workerKinds, + loadCorePlusApi, + createCoreClient + ); + }; + private _isQueryOwned = ( querySerialOrInfo: QuerySerial | QueryInfo ): boolean => { @@ -559,6 +659,8 @@ export class DheService implements IDheService { dispose = async (): Promise => { const querySerials = [...this._querySerialSet]; + const managerPromise = this._corePlusManagerPromise; + this._corePlusManagerPromise = null; this._querySerialSet.clear(); this._removeConfigListeners?.(); @@ -569,6 +671,11 @@ export class DheService implements IDheService { await Promise.all([ this._workerInfoMap.dispose(), this._disposeQueries(querySerials), + managerPromise + ?.then(manager => manager?.dispose()) + .catch(err => { + logger.error('Failed to dispose CorePlusManager', err); + }), ]); }; } diff --git a/src/services/QueryConfigTableService.spec.ts b/src/services/QueryConfigTableService.spec.ts new file mode 100644 index 000000000..6db0b3fb7 --- /dev/null +++ b/src/services/QueryConfigTableService.spec.ts @@ -0,0 +1,186 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import { getQueryTableFilters } from './QueryConfigTableService'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +/** A recorded filter condition produced by the mocked filter builder. */ +interface RecordedCondition { + column: string; + op: string; + terms?: unknown[]; + term?: unknown; + not?: () => RecordedCondition; +} + +/** Mocked filter builder returned by `column.filter()`. */ +interface MockFilterValue { + in: (terms: unknown[]) => RecordedCondition; + containsIgnoreCase: (term: unknown) => RecordedCondition; +} + +function makeFilterValue(name: string): MockFilterValue { + return { + // `.in(...)` supports chaining `.not()` for the helper-type exclusion branch. + in: (terms: unknown[]): RecordedCondition => ({ + column: name, + op: 'in', + terms, + not: (): RecordedCondition => ({ column: name, op: 'notIn', terms }), + }), + containsIgnoreCase: (term: unknown): RecordedCondition => ({ + column: name, + op: 'containsIgnoreCase', + term, + }), + }; +} + +/** + * Build a mocked `dh.Table` whose `findColumn(name).filter()` returns a filter + * builder that records the operations invoked on it. Each produced + * `FilterCondition` records the column it came from and the operation applied, + * so tests can assert the correct server-side filters were constructed. + */ +function createMockTable(): { + table: DhcType.Table; + findColumn: ReturnType; +} { + const findColumn = vi.fn((name: string) => ({ + name, + filter: (): MockFilterValue => makeFilterValue(name), + })); + + return { + table: { findColumn } as unknown as DhcType.Table, + findColumn, + }; +} + +const dh = { + // eslint-disable-next-line @typescript-eslint/naming-convention + FilterValue: { + ofString: (value: string): { value: string } => ({ value }), + }, +} as unknown as DheType; + +describe('getQueryTableFilters', () => { + let mock: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mock = createMockTable(); + }); + + it('returns no conditions for empty filters', () => { + const conditions = getQueryTableFilters(dh, mock.table, {}); + expect(conditions).toHaveLength(0); + expect(mock.findColumn).not.toHaveBeenCalled(); + }); + + it('builds an `in` filter on Owner', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: ['alice', 'bob'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'Owner', + op: 'in', + terms: [{ value: 'alice' }, { value: 'bob' }], + }); + }); + + it('builds an `in` filter on QueryType', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + types: ['InteractiveConsole'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'in', + terms: [{ value: 'InteractiveConsole' }], + }); + }); + + it('builds an `in` filter on Status', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + statuses: ['Running'], + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'Status', + op: 'in', + terms: [{ value: 'Running' }], + }); + }); + + it('builds a case-insensitive contains filter on Name for search', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + search: 'my-query', + }); + expect(conditions).toEqual([ + { + column: 'Name', + op: 'containsIgnoreCase', + term: { value: 'my-query' }, + }, + ]); + }); + + it('ignores an empty search string', () => { + const conditions = getQueryTableFilters(dh, mock.table, { search: '' }); + expect(conditions).toHaveLength(0); + }); + + it('ignores empty owner/type/status arrays', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: [], + types: [], + statuses: [], + }); + expect(conditions).toHaveLength(0); + }); + + it('excludes helper query types via `not in` when excludeHelperTypes is set', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + excludeHelperTypes: true, + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'notIn', + }); + }); + + it('prefers an explicit type allow-list over excludeHelperTypes', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + types: ['InteractiveConsole'], + excludeHelperTypes: true, + }); + expect(conditions).toHaveLength(1); + expect(conditions[0]).toMatchObject({ + column: 'QueryType', + op: 'in', + terms: [{ value: 'InteractiveConsole' }], + }); + }); + + it('ANDs multiple filters together in order', () => { + const conditions = getQueryTableFilters(dh, mock.table, { + owners: ['alice'], + types: ['InteractiveConsole'], + statuses: ['Running'], + search: 'foo', + }); + expect( + conditions.map(c => (c as unknown as { column: string }).column) + ).toEqual([ + 'Owner', + 'QueryType', + 'Status', + 'Name', + ]); + }); +}); diff --git a/src/services/QueryConfigTableService.ts b/src/services/QueryConfigTableService.ts new file mode 100644 index 000000000..257b6e338 --- /dev/null +++ b/src/services/QueryConfigTableService.ts @@ -0,0 +1,267 @@ +import * as vscode from 'vscode'; +import type { dh as DhcType } from '@deephaven/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import { + EXCLUDED_QUERY_TYPES, + makeFactoryServiceTablePromise, + QueryColumns, + QUERY_CONFIG_TABLE, + WEB_CLIENT_DATA_CORE_QUERY, +} from '@deephaven-enterprise/query-utils'; +import type { + IAsyncCacheService, + IDheService, + IDisposable, +} from '../types'; +import { Logger } from '../util'; +import { DisposableBase } from './DisposableBase'; + +const logger = new Logger('QueryConfigTableService'); + +/** + * Error thrown when the `WebClientData` Core+ system query required to fetch the + * `QueryInfo` table is unavailable (not visible to the current user or not + * running). This surfaces a clear failure instead of hanging (Gotcha 7). + */ +export class WebClientDataUnavailableError extends Error { + constructor(serverUrl: URL) { + super( + `The '${WEB_CLIENT_DATA_CORE_QUERY}' system query is unavailable on ${serverUrl}. ` + + `The Persistent Queries table cannot be loaded until it is running and visible to you.` + ); + this.name = 'WebClientDataUnavailableError'; + } +} + +/** + * Server-side filters to apply to the `QueryInfo` table. All fields are + * optional; only provided fields are applied. Multiple fields are AND'd + * together. Mirrors iris `PQExplorerPanel.getQueryTableFilters`. + */ +export interface QueryTableFilters { + /** Restrict to queries owned by these owners (OR'd). */ + owners?: readonly string[]; + /** Restrict to these query types (OR'd), e.g. `InteractiveConsole`. */ + types?: readonly string[]; + /** Restrict to these statuses (OR'd), e.g. `Running`. */ + statuses?: readonly string[]; + /** Case-insensitive substring match against the query name. */ + search?: string; + /** + * When true, exclude the query types the PQ explorer never lists (helper / + * system queries) via `EXCLUDED_QUERY_TYPES`. Defaults to false. + */ + excludeHelperTypes?: boolean; +} + +/** + * Build the server-side `FilterCondition[]` for the `QueryInfo` table from the + * given filters. Pure — no I/O or subscription side effects, so it can be unit + * tested against a mocked table. Mirrors `PQExplorerPanel.getQueryTableFilters`. + * @param dh The (enterprise) DH API providing `FilterValue`. + * @param table The `QueryInfo` table to build columns/filters from. + * @param filters The filters to apply. + * @returns An array of `FilterCondition` to pass to `table.applyFilter`. + */ +export function getQueryTableFilters( + dh: DheType, + table: DhcType.Table, + filters: QueryTableFilters +): DhcType.FilterCondition[] { + const conditions: DhcType.FilterCondition[] = []; + + const inColumn = ( + columnName: string, + values: readonly string[] + ): DhcType.FilterCondition | null => { + if (values.length === 0) { + return null; + } + + const filterValue = table.findColumn(columnName).filter(); + return filterValue.in(values.map(value => dh.FilterValue.ofString(value))); + }; + + const pushIfNonEmpty = ( + condition: DhcType.FilterCondition | null + ): void => { + if (condition != null) { + conditions.push(condition); + } + }; + + if (filters.owners != null) { + pushIfNonEmpty(inColumn(QueryColumns.OWNER.name, filters.owners)); + } + + if (filters.types != null) { + // Explicit type allow-list takes precedence over the helper-type exclusion. + pushIfNonEmpty(inColumn(QueryColumns.QUERY_TYPE.name, filters.types)); + } else if (filters.excludeHelperTypes === true) { + // Exclude the helper/system query types the PQ explorer never lists. + const excluded = [...EXCLUDED_QUERY_TYPES]; + if (excluded.length > 0) { + conditions.push( + table + .findColumn(QueryColumns.QUERY_TYPE.name) + .filter() + .in(excluded.map(value => dh.FilterValue.ofString(value))) + .not() + ); + } + } + + if (filters.statuses != null) { + pushIfNonEmpty(inColumn(QueryColumns.STATUS.name, filters.statuses)); + } + + if (filters.search != null && filters.search.length > 0) { + const nameFilter = table.findColumn(QueryColumns.NAME.name).filter(); + conditions.push( + nameFilter.containsIgnoreCase(dh.FilterValue.ofString(filters.search)) + ); + } + + return conditions; +} + +/** + * A subscription over a filtered `QueryInfo` table. Emits the current set of + * filtered rows on every server tick (`EVENT_UPDATED`) until disposed. + */ +export interface QueryInfoTableSubscription extends IDisposable { + /** The underlying (filtered) `QueryInfo` table. */ + readonly table: DhcType.Table; + /** Fires with a snapshot of the current viewport rows on each update. */ + readonly onDidUpdate: vscode.Event; +} + +/** + * Reusable service exposing a server-side-filtered, ticking subscription over + * the Core+ `QueryInfo` table (built on the `EnterpriseCorePlusManager` + * substrate). Consumed by the Persistent Queries explorer feature. + */ +export class QueryConfigTableService extends DisposableBase { + /** + * @param serverUrl The DHE server URL this service is scoped to. + * @param dheService The DHE service providing the authenticated client and + * the `CorePlusManager`. + * @param dheJsApiCache Cache providing the DHE JS API for `serverUrl`. + */ + constructor( + serverUrl: URL, + dheService: IDheService, + dheJsApiCache: IAsyncCacheService + ) { + super(); + this._serverUrl = serverUrl; + this._dheService = dheService; + this._dheJsApiCache = dheJsApiCache; + } + + private readonly _serverUrl: URL; + private readonly _dheService: IDheService; + private readonly _dheJsApiCache: IAsyncCacheService; + + /** + * Fetch the (unfiltered) `QueryInfo` table via the WebClientData factory + * service. Throws a clear error when `WebClientData` is unavailable + * (Gotcha 7) rather than hanging on the upstream widget-message path. + * @returns The `QueryInfo` table. + */ + private async _fetchQueryInfoTable(): Promise { + const dheClient = await this._dheService.getClient(false); + if (dheClient == null) { + throw new Error( + `Cannot fetch '${QUERY_CONFIG_TABLE}' table: DHE client is not available for ${this._serverUrl}.` + ); + } + + const corePlusManager = await this._dheService.getCorePlusManager(); + if (corePlusManager == null) { + throw new Error( + `Cannot fetch '${QUERY_CONFIG_TABLE}' table: CorePlusManager is not available for ${this._serverUrl}.` + ); + } + + // Pre-check that the always-on WebClientData system query is visible + + // running. `makeFactoryServiceTablePromise` routes through a widget-message + // helper whose rejection is not reliably propagated (upstream DH-20345), so + // guard here to surface a clear error instead of hanging (Gotcha 7). + const hasWebClientData = dheClient.client + .getKnownConfigs() + .some( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + + if (!hasWebClientData) { + throw new WebClientDataUnavailableError(this._serverUrl); + } + + const dhe = await this._dheJsApiCache.get(this._serverUrl); + const userInfo = await dheClient.client.getUserInfo(); + + return makeFactoryServiceTablePromise({ + client: dheClient.client, + corePlusManager, + dh: dhe, + userInfo, + tableName: QUERY_CONFIG_TABLE, + }); + } + + /** + * Get a filtered, ticking `QueryInfo` table subscription. The returned + * subscription emits a viewport snapshot on every update and must be disposed + * by the caller (or with this service). + * @param filters Server-side filters to apply. + * @returns The subscription. + */ + getQueryInfoTable = async ( + filters: QueryTableFilters = {} + ): Promise => { + const dhe = await this._dheJsApiCache.get(this._serverUrl); + const table = await this._fetchQueryInfoTable(); + + const conditions = getQueryTableFilters(dhe, table, filters); + table.applyFilter(conditions); + + const onDidUpdateEmitter = + new vscode.EventEmitter(); + + const onUpdate = ({ + detail, + }: DhcType.Event): void => { + onDidUpdateEmitter.fire(detail); + }; + + const removeUpdateListener = table.addEventListener( + dhe.Table.EVENT_UPDATED, + onUpdate + ); + + // Subscribe to all rows so the table ticks. The viewport is refreshed as + // the filtered size changes. + table.setViewport(0, Math.max(0, table.size - 1)); + + const subscription: QueryInfoTableSubscription = { + table, + onDidUpdate: onDidUpdateEmitter.event, + dispose: async (): Promise => { + removeUpdateListener(); + onDidUpdateEmitter.dispose(); + try { + table.close(); + } catch (err) { + logger.debug('Error closing QueryInfo table', err); + } + }, + }; + + this.disposables.add(subscription); + + return subscription; + }; +} diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 4fe0405a7..ecce490c0 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import type { CorePlusManager } from '@deephaven-enterprise/client-utils'; import type { ConsoleType, CoreConnectionConfig, @@ -92,6 +93,7 @@ export interface IDheService extends ConnectionState, IDisposable { initializeIfNull: true, operateAsAnotherUser: boolean ): Promise; + getCorePlusManager(): Promise; getQuerySerialFromTag(tagId: UniqueID): Promise; getServerFeatures(): DheServerFeatures | undefined; getWorkerInfo: (workerUrl: WorkerURL) => WorkerInfo | undefined; From ae828039dfec406f027ecc63979c1e560a15434a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 27 Jul 2026 18:17:44 -0500 Subject: [PATCH 27/68] Add Persistent Queries explorer tree view (#DH-22593) Add a dedicated "Persistent Queries" tree view that lists ACL-visible running non-InteractiveConsole PQs and lets you open their exported objects read-only. - PersistentQueryTreeProvider (extends ServerTreeProviderBase): DHE server -> filtered PQ nodes -> object leaves. PQ serials come from the ticking QueryInfo table (QueryConfigTableService, server-side filtered to running + non-helper types), resolved to full QueryInfo via getKnownConfigs(). Objects are read straight off queryInfo.designated.objects, so there is no connect-on-expand / subscribeToFieldUpdates lifecycle. - Object leaves reuse getPanelVariableTreeItem + OPEN_VARIABLE_PANELS_CMD, so selecting an object opens the existing dhPanel embed webview unchanged. - ServerManager.registerBrowseConnection / unregisterBrowseConnection: a lightweight non-console ConnectionState (+ worker-URL->server auth mapping + registerWorkerInfo) so the embed iframe can authenticate. Browse connections never initSession, never increment connectionCount, never enter _attachedWorkerSerials, and never delete the server-side PQ. - Register the vscode-deephaven.view.persistentQueryTree view, a refresh title command, and createTreeView wiring in ExtensionController. - Unit tests for the provider + tree-item helpers; headless validation script (scripts/validate-pq-explorer.mjs) and manual smoke doc (docs/manual-smoke-pq-explorer.md) for requester-run verification. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- docs/manual-smoke-pq-explorer.md | 59 ++++ package.json | 16 + scripts/validate-pq-explorer.mjs | 159 ++++++++++ src/common/commands.ts | 3 + src/common/constants.ts | 8 + src/controllers/ExtensionController.ts | 36 +++ .../PersistentQueryTreeProvider.spec.ts | 265 ++++++++++++++++ src/providers/PersistentQueryTreeProvider.ts | 286 ++++++++++++++++++ src/providers/index.ts | 1 + src/services/ServerManager.ts | 81 +++++ src/services/index.ts | 1 + src/testUtils.ts | 31 ++ src/types/serviceTypes.d.ts | 12 + src/types/treeViewTypes.d.ts | 26 ++ src/util/treeViewUtils.spec.ts | 111 +++++++ src/util/treeViewUtils.ts | 83 +++++ 16 files changed, 1178 insertions(+) create mode 100644 docs/manual-smoke-pq-explorer.md create mode 100644 scripts/validate-pq-explorer.mjs create mode 100644 src/providers/PersistentQueryTreeProvider.spec.ts create mode 100644 src/providers/PersistentQueryTreeProvider.ts diff --git a/docs/manual-smoke-pq-explorer.md b/docs/manual-smoke-pq-explorer.md new file mode 100644 index 000000000..52ade409e --- /dev/null +++ b/docs/manual-smoke-pq-explorer.md @@ -0,0 +1,59 @@ +# Manual Smoke — Persistent Queries Explorer + +These checks verify the **Persistent Queries** tree view (browse ACL-visible +non-InteractiveConsole PQs and open their objects read-only). They require a live +Core+ (Deephaven Enterprise) server with the always-on `WebClientData` system +query running, and **cannot** be run in the devcontainer (the extension UI does +not run there). Run them from a full VS Code instance connected to a real server. + +The automated gates (`npm run build:ts`, `npm run test:unit`, +`npm run test:lint`) are the developer bar; the items below are the +requester-run verification. + +## Headless plumbing check (optional, no UI) + +Proves the table filter + non-console object enumeration without the extension: + +``` +DHE_SERVER_URL=https://my-dhe:8123 \ +DHE_USERNAME=me DHE_PASSWORD=secret \ +node scripts/validate-pq-explorer.mjs +``` + +- [ ] Prints the filtered running non-InteractiveConsole PQ rows. +- [ ] Prints the exported objects of one running non-IC PQ. + (`NODE_EXTRA_CA_CERTS` may be needed for self-signed / internal-CA servers.) + +## UI smoke + +### List + +- [ ] Connect to a DHE server. The **Persistent Queries** view (in the Deephaven + activity-bar container, below **Panels**) shows the DHE server node. +- [ ] Expanding the server node lists **running non-InteractiveConsole** PQs + (owner shown as the node description). InteractiveConsole workers do **not** + appear here — they remain in the **Workers** tree. +- [ ] The list reflects server-side changes: start/stop a PQ from another client + (e.g. the web UI); the tree updates within a few seconds (table tick). Use the + title-bar refresh action to force a refresh. + +### Open objects + +- [ ] Expand a running PQ node. Its exported objects (tables, figures, widgets) + are listed as leaves with the correct type icons. +- [ ] Click a **Table** object. It opens read-only in a `dhPanel` webview and + renders data. +- [ ] Click a **Figure** object. It opens read-only in a `dhPanel` webview and + renders. +- [ ] No console session is created for the PQ: it does **not** appear in the + **Workers** tree, the server's worker/connection count is unchanged, and no + "Created Deephaven session" toast appears. + +### Ownership / lifecycle invariants + +- [ ] Close the object panels and collapse the PQ node — the server-side PQ keeps + running (it is never deleted by this view). +- [ ] Disconnect the DHE server — the Persistent Queries view empties for that + server and no residual browse connections leak; the PQ (owned by whoever + created it) is left running server-side. +- [ ] Reconnect — the PQ list repopulates and objects can be opened again. diff --git a/package.json b/package.json index 49bc9255d..242a836e1 100644 --- a/package.json +++ b/package.json @@ -309,6 +309,11 @@ "title": "Refresh Panels Tree", "icon": "$(refresh)" }, + { + "command": "vscode-deephaven.refreshPersistentQueryTree", + "title": "Refresh Persistent Queries", + "icon": "$(refresh)" + }, { "command": "vscode-deephaven.refreshRemoteImportSourceTree", "title": "Refresh Remote Import Source Tree", @@ -1001,6 +1006,11 @@ "group": "navigation", "when": "view == vscode-deephaven.view.serverConnectionPanelTree" }, + { + "command": "vscode-deephaven.refreshPersistentQueryTree", + "group": "navigation", + "when": "view == vscode-deephaven.view.persistentQueryTree" + }, { "command": "vscode-deephaven.searchConnections", "group": "navigation", @@ -1135,6 +1145,12 @@ "type": "tree", "icon": "images/dh-logo-28.svg" }, + { + "id": "vscode-deephaven.view.persistentQueryTree", + "name": "Persistent Queries", + "type": "tree", + "icon": "images/dh-logo-28.svg" + }, { "id": "vscode-deephaven.view.remoteImportSourceTree", "name": "Remote Import Sources", diff --git a/scripts/validate-pq-explorer.mjs b/scripts/validate-pq-explorer.mjs new file mode 100644 index 000000000..3493c9079 --- /dev/null +++ b/scripts/validate-pq-explorer.mjs @@ -0,0 +1,159 @@ +// @ts-check +/** + * Headless validation for the Persistent Queries explorer plumbing. + * + * Proves — WITHOUT the extension UI — that against a live Core+ (Deephaven + * Enterprise) server we can: + * 1. build a `CorePlusManager`, + * 2. fetch the ticking `QueryInfo` table, + * 3. apply the PQ-explorer server-side filter (running, non-helper types), + * 4. print the filtered PQ rows, + * 5. pick one running non-InteractiveConsole PQ and enumerate its exported + * objects (from `queryInfo.designated.objects`, the same source the tree + * provider uses — no console session / no `initSession`). + * + * This is DEFERRED to the requester: it cannot run in the devcontainer (no live + * server). Run it from a machine with network access to a Core+ server. + * + * Usage: + * DHE_SERVER_URL=https://my-dhe:8123 \ + * DHE_USERNAME=me DHE_PASSWORD=secret \ + * node scripts/validate-pq-explorer.mjs + * + * Requires the same `@deephaven-enterprise/*` packages the extension depends on + * (already installed). `NODE_EXTRA_CA_CERTS` may be needed for self-signed / + * internal-CA servers (see the jsapi-nodejs README). + */ + +import { initCorePlusManager } from '@deephaven-enterprise/jsapi-nodejs'; +import { createPasswordCredentials } from '@deephaven-enterprise/auth-nodejs'; +import { + makeFactoryServiceTablePromise, + QueryColumns, + QUERY_CONFIG_TABLE, + EXCLUDED_QUERY_TYPES, + WEB_CLIENT_DATA_CORE_QUERY, +} from '@deephaven-enterprise/query-utils'; + +const INTERACTIVE_CONSOLE_QUERY_TYPE = 'InteractiveConsole'; + +function requireEnv(name) { + const value = process.env[name]; + if (value == null || value === '') { + console.error(`Missing required env var: ${name}`); + process.exit(2); + } + return value; +} + +async function main() { + const serverUrl = new URL(requireEnv('DHE_SERVER_URL')); + const username = requireEnv('DHE_USERNAME'); + const password = requireEnv('DHE_PASSWORD'); + + console.log(`Connecting to ${serverUrl} as ${username}...`); + + const manager = await initCorePlusManager({ + serverUrl, + credentials: createPasswordCredentials(username, password), + }); + + try { + const dheClient = manager.dheClient; + const dhe = manager.dhe; + const userInfo = await dheClient.getUserInfo(); + + // Guard: WebClientData must be running + visible (Gotcha 7). + const hasWebClientData = dheClient + .getKnownConfigs() + .some( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + if (!hasWebClientData) { + console.error( + `'${WEB_CLIENT_DATA_CORE_QUERY}' is not running/visible — cannot load the QueryInfo table.` + ); + process.exit(1); + } + + console.log(`Fetching '${QUERY_CONFIG_TABLE}' table...`); + const table = await makeFactoryServiceTablePromise({ + client: dheClient, + corePlusManager: manager, + dh: dhe, + userInfo, + tableName: QUERY_CONFIG_TABLE, + }); + + // Server-side filter: running, excluding helper/system types (which include + // InteractiveConsole). Mirrors the tree provider's filter (Decision 3). + const statusFilter = table + .findColumn(QueryColumns.STATUS.name) + .filter() + .in([dhe.FilterValue.ofString('Running')]); + + const excludeTypeFilter = table + .findColumn(QueryColumns.QUERY_TYPE.name) + .filter() + .in([...EXCLUDED_QUERY_TYPES].map(t => dhe.FilterValue.ofString(t))) + .not(); + + table.applyFilter([statusFilter, excludeTypeFilter]); + table.setViewport(0, Math.max(0, table.size - 1)); + + // Give the viewport a moment to populate. + await new Promise(resolve => setTimeout(resolve, 2000)); + + const viewportData = await table.getViewportData(); + const serialColumn = table.findColumn(QueryColumns.SERIAL.name); + const nameColumn = table.findColumn(QueryColumns.NAME.name); + const parentColumn = table.findColumn(QueryColumns.PARENT_ID.name); + + console.log(`\nFiltered running non-helper PQs (${viewportData.rows.length}):`); + const serials = []; + for (const row of viewportData.rows) { + const parent = row.get(parentColumn); + if (parent != null && String(parent).length > 0) { + continue; // skip child-replica rows + } + const serial = String(row.get(serialColumn)); + serials.push(serial); + console.log(` - ${row.get(nameColumn)} [serial ${serial}]`); + } + + // Resolve one PQ's full QueryInfo and enumerate its objects. + const pq = dheClient + .getKnownConfigs() + .find( + qi => + qi.serial != null && + serials.includes(String(qi.serial)) && + qi.type !== INTERACTIVE_CONSOLE_QUERY_TYPE && + qi.designated?.status === 'Running' + ); + + if (pq == null) { + console.log('\nNo running non-IC PQ available to enumerate objects for.'); + } else { + const objects = pq.designated?.objects ?? []; + console.log( + `\nObjects for PQ '${pq.name}' (worker ${pq.designated?.jsApiUrl}):` + ); + for (const obj of objects) { + console.log(` - ${obj.title} (${obj.type})`); + } + } + + table.close(); + console.log('\nDone.'); + } finally { + await manager.dispose(); + } +} + +main().catch(err => { + console.error('Validation failed:', err); + process.exit(1); +}); diff --git a/src/common/commands.ts b/src/common/commands.ts index 3f78a953e..9da88aa7e 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -120,6 +120,9 @@ export const REFRESH_SERVER_CONNECTION_TREE_CMD = cmd( 'refreshServerConnectionTree' ); export const REFRESH_PANELS_TREE_CMD = cmd('refreshPanelsTree'); +export const REFRESH_PERSISTENT_QUERY_TREE_CMD = cmd( + 'refreshPersistentQueryTree' +); export const REFRESH_VARIABLE_PANELS_CMD = cmd('refreshVariablePanels'); export const REMOVE_GROOVY_REMOTE_FILE_SOURCE_CMD = cmd( 'removeGroovyRemoteFileSource' diff --git a/src/common/constants.ts b/src/common/constants.ts index 90a597b66..1e10ae2ce 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -114,6 +114,7 @@ export const VIEW_ID = { serverTree: `${VIEW_ID_PREFIX}serverTree`, serverConnectionTree: `${VIEW_ID_PREFIX}serverConnectionTree`, serverConnectionPanelTree: `${VIEW_ID_PREFIX}serverConnectionPanelTree`, + persistentQueryTree: `${VIEW_ID_PREFIX}persistentQueryTree`, variablePanel: `${VIEW_ID_PREFIX}variablePanel`, } as const; @@ -165,6 +166,13 @@ export const CONNECTION_TREE_ITEM_CONTEXT = { isUri: 'isUri', } as const; +export const PERSISTENT_QUERY_TREE_ITEM_CONTEXT = { + /** A DHE server grouping its persistent queries. */ + isPersistentQueryServer: 'isPersistentQueryServer', + /** A (non-InteractiveConsole) persistent query node. */ + isPersistentQuery: 'isPersistentQuery', +} as const; + export const PIP_SERVER_STATUS_DIRECTORY = 'pip-server-status'; export const SERVER_TREE_ITEM_CONTEXT = { diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 00e66c7d1..072275ad3 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -22,6 +22,7 @@ import { GENERATE_REQUIREMENTS_TXT_CMD, OPEN_IN_BROWSER_CMD, REFRESH_PANELS_TREE_CMD, + REFRESH_PERSISTENT_QUERY_TREE_CMD, REFRESH_REMOTE_IMPORT_SOURCE_TREE_CMD, REFRESH_SERVER_CONNECTION_TREE_CMD, REFRESH_SERVER_TREE_CMD, @@ -70,6 +71,7 @@ import { ServerTreeProvider, ServerConnectionTreeProvider, ServerConnectionPanelTreeProvider, + PersistentQueryTreeProvider, runSelectedLinesHoverProvider, RunMarkdownCodeBlockCodeLensProvider, SamlAuthProvider, @@ -107,6 +109,8 @@ import type { IToastService, ServerConnectionPanelNode, ServerConnectionPanelTreeView, + PersistentQueryTreeNode, + PersistentQueryTreeView, ServerConnectionTreeView, ServerState, ServerTreeView, @@ -213,6 +217,8 @@ export class ExtensionController implements IDisposable { null; private _serverConnectionPanelTreeProvider: ServerConnectionPanelTreeProvider | null = null; + private _persistentQueryTreeProvider: PersistentQueryTreeProvider | null = + null; private _remoteImportSourceTreeProvider: RemoteImportSourceTreeProvider | null = null; @@ -221,6 +227,7 @@ export class ExtensionController implements IDisposable { private _serverConnectionTreeView: ServerConnectionTreeView | null = null; private _serverConnectionPanelTreeView: ServerConnectionPanelTreeView | null = null; + private _persistentQueryTreeView: PersistentQueryTreeView | null = null; private _remoteImportSourceTreeView: RemoteImportSourceTreeView | null = null; // Web views @@ -825,6 +832,12 @@ export class ExtensionController implements IDisposable { /** Refresh variable panels tree */ this.registerCommand(REFRESH_PANELS_TREE_CMD, this.onRefreshServerStatus); + /** Refresh persistent query tree */ + this.registerCommand( + REFRESH_PERSISTENT_QUERY_TREE_CMD, + this.onRefreshPersistentQueryTree + ); + /** Remote import source tree */ this.registerCommand( REFRESH_REMOTE_IMPORT_SOURCE_TREE_CMD, @@ -875,6 +888,8 @@ export class ExtensionController implements IDisposable { */ initializeWebViews = (): void => { assertDefined(this._dheClientCache, 'dheClientCache'); + assertDefined(this._dheJsApiCache, 'dheJsApiCache'); + assertDefined(this._dheServiceCache, 'dheServiceCache'); assertDefined(this._groovyWorkspace, 'groovyWorkspace'); assertDefined(this._pythonWorkspace, 'pythonWorkspace'); assertDefined(this._panelService, 'panelService'); @@ -935,6 +950,21 @@ export class ExtensionController implements IDisposable { } ); + // Persistent Queries tree + this._persistentQueryTreeProvider = new PersistentQueryTreeProvider( + this._serverManager, + this._dheServiceCache, + this._dheJsApiCache + ); + this._persistentQueryTreeView = + vscode.window.createTreeView( + VIEW_ID.persistentQueryTree, + { + showCollapseAll: true, + treeDataProvider: this._persistentQueryTreeProvider, + } + ); + // Remote import source tree this._remoteImportSourceTreeProvider = new RemoteImportSourceTreeProvider( this._groovyWorkspace, @@ -953,10 +983,12 @@ export class ExtensionController implements IDisposable { this._serverTreeView, this._serverConnectionTreeView, this._serverConnectionPanelTreeView, + this._persistentQueryTreeView, this._remoteImportSourceTreeView, this._serverTreeProvider, this._serverConnectionTreeProvider, this._serverConnectionPanelTreeProvider, + this._persistentQueryTreeProvider, this._remoteImportSourceTreeProvider ); }; @@ -1213,6 +1245,10 @@ export class ExtensionController implements IDisposable { await this._serverManager?.updateStatus(); }; + onRefreshPersistentQueryTree = async (): Promise => { + this._persistentQueryTreeProvider?.refresh(); + }; + onRevealInExplorer = async ( uriOrHasUri: vscode.Uri | { uri: vscode.Uri } | undefined ): Promise => { diff --git a/src/providers/PersistentQueryTreeProvider.spec.ts b/src/providers/PersistentQueryTreeProvider.spec.ts new file mode 100644 index 000000000..48f46133b --- /dev/null +++ b/src/providers/PersistentQueryTreeProvider.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import { PersistentQueryTreeProvider } from './PersistentQueryTreeProvider'; +import { getQueryInfoTableMock } from '../testUtils'; +import type { + IAsyncCacheService, + IDheService, + IServerManager, + PersistentQueryNode, + PersistentQueryTreeNode, + ServerState, + VariableDefintion, + WorkerInfo, +} from '../types'; +import { OPEN_VARIABLE_PANELS_CMD } from '../common'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +// Control the QueryConfigTableService the provider constructs internally. +const getQueryInfoTable = vi.fn(); +vi.mock('../services', async importActual => { + const actual = await importActual(); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/naming-convention + QueryConfigTableService: class { + getQueryInfoTable = getQueryInfoTable; + dispose = vi.fn(async () => {}); + }, + }; +}); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); +const WORKER_URL = new URL('https://dhe.example.com:8123/worker/1/'); + +/** Build a minimal `QueryInfo` for tests. */ +function makeQueryInfo(overrides: Partial = {}): QueryInfo { + return { + serial: 'serial-1', + name: 'My PQ', + owner: 'alice', + type: 'DeephavenCommunity', + designated: { + status: 'Running', + objects: [ + { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, + { title: 'my_figure', name: 'my_figure', type: 'Figure', id: 'v2' }, + ], + }, + ...overrides, + } as unknown as QueryInfo; +} + +function makeServerState(overrides: Partial = {}): ServerState { + return { + type: 'DHE', + url: DHE_URL, + label: 'DHE', + isConnected: true, + isRunning: true, + connectionCount: 0, + ...overrides, + } as ServerState; +} + +describe('PersistentQueryTreeProvider', () => { + let serverManager: IServerManager; + let dheService: IDheService; + let dheServiceCache: IAsyncCacheService; + let dheJsApiCache: IAsyncCacheService; + let provider: PersistentQueryTreeProvider; + let knownConfigs: QueryInfo[]; + + beforeEach(() => { + vi.clearAllMocks(); + + knownConfigs = [makeQueryInfo()]; + + dheService = { + getClient: vi.fn(async () => ({ + client: { + getKnownConfigs: vi.fn(() => knownConfigs), + }, + })), + } as unknown as IDheService; + + dheServiceCache = { + get: vi.fn(async () => dheService), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + dheJsApiCache = { + get: vi.fn(async () => ({}) as never), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + serverManager = { + onDidUpdate: vi.fn(() => vi.fn()), + onDidDisconnect: vi.fn(() => vi.fn()), + getServers: vi.fn(() => [makeServerState()]), + registerBrowseConnection: vi.fn( + async (): Promise => + ({ workerUrl: WORKER_URL, name: 'My PQ' }) as WorkerInfo + ), + } as unknown as IServerManager; + + provider = new PersistentQueryTreeProvider( + serverManager, + dheServiceCache, + dheJsApiCache + ); + }); + + describe('getChildren (root)', () => { + it('returns only connected DHE servers', async () => { + (serverManager.getServers as ReturnType).mockReturnValue([ + makeServerState({ isConnected: true }), + makeServerState({ + url: new URL('https://other.example.com/'), + isConnected: false, + }), + ]); + + const children = (await provider.getChildren()) as ServerState[]; + expect(children).toHaveLength(1); + expect(children[0].isConnected).toBe(true); + expect(serverManager.getServers).toHaveBeenCalledWith({ type: 'DHE' }); + }); + }); + + describe('getChildren (server -> persistent queries)', () => { + it('lists running non-IC PQs resolved from getKnownConfigs, excluding IC + replica children', async () => { + knownConfigs = [ + makeQueryInfo({ serial: 'serial-1', name: 'Zeta PQ' }), + makeQueryInfo({ serial: 'serial-2', name: 'Alpha PQ' }), + // InteractiveConsole — must be excluded. + makeQueryInfo({ serial: 'serial-ic', type: 'InteractiveConsole' }), + // Not in the (filtered) table viewport — excluded. + makeQueryInfo({ serial: 'serial-hidden', name: 'Hidden' }), + ]; + + getQueryInfoTable.mockResolvedValue( + getQueryInfoTableMock({ + // Serial column values in the filtered viewport; `serial-hidden` + // omitted, `serial-child` has a Parent set. + /* eslint-disable @typescript-eslint/naming-convention */ + rows: [ + { Serial: 'serial-1', Parent: null }, + { Serial: 'serial-2', Parent: null }, + { Serial: 'serial-ic', Parent: null }, + { Serial: 'serial-child', Parent: 'serial-1' }, + ], + /* eslint-enable @typescript-eslint/naming-convention */ + }) + ); + + const server = makeServerState(); + const children = (await provider.getChildren( + server + )) as PersistentQueryNode[]; + + const names = children.map(c => c.queryInfo.name); + // Sorted by name; IC + hidden excluded. + expect(names).toEqual(['Alpha PQ', 'Zeta PQ']); + children.forEach(c => expect(c.dheServerUrl).toBe(server.url)); + }); + + it('applies a running + exclude-helper-types server-side filter', async () => { + getQueryInfoTable.mockResolvedValue( + getQueryInfoTableMock({ + // eslint-disable-next-line @typescript-eslint/naming-convention + rows: [{ Serial: 'serial-1', Parent: null }], + }) + ); + + await provider.getChildren(makeServerState()); + + expect(getQueryInfoTable).toHaveBeenCalledWith({ + statuses: ['Running'], + excludeHelperTypes: true, + }); + }); + + it('returns an empty list when the table fails to load', async () => { + getQueryInfoTable.mockRejectedValue(new Error('WebClientData down')); + + const children = await provider.getChildren(makeServerState()); + expect(children).toEqual([]); + }); + }); + + describe('getChildren (PQ -> object leaves)', () => { + it('registers a browse connection and returns object leaves opened via OPEN_VARIABLE_PANELS_CMD', async () => { + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo(), + }; + + const leaves = (await provider.getChildren(node)) as [ + URL, + VariableDefintion, + ][]; + + expect(serverManager.registerBrowseConnection).toHaveBeenCalledWith( + DHE_URL, + node.queryInfo + ); + + expect(leaves).toHaveLength(2); + expect(leaves.map(([, v]) => v.title)).toEqual([ + 'my_table', + 'my_figure', + ]); + // Leaves are keyed by the worker URL (from the browse connection). + leaves.forEach(([url]) => expect(url.href).toBe(WORKER_URL.href)); + + // Verify the open command wiring via the tree item. + const item = provider.getTreeItem(leaves[0]); + expect(item.command?.command).toBe(OPEN_VARIABLE_PANELS_CMD); + expect(item.command?.arguments?.[0]).toBe(leaves[0][0]); + }); + + it('returns no leaves when the browse connection cannot be registered', async () => { + ( + serverManager.registerBrowseConnection as ReturnType + ).mockResolvedValue(null); + + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo(), + }; + + const leaves = await provider.getChildren(node); + expect(leaves).toEqual([]); + }); + }); + + describe('getTreeItem', () => { + it('renders a DHE server node', () => { + const item = provider.getTreeItem(makeServerState()); + expect(item.label).toBe('DHE'); + }); + + it('renders a persistent-query node with status icon + name', () => { + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo({ name: 'My PQ' }), + }; + const item = provider.getTreeItem(node); + expect(item.label).toBe('My PQ'); + expect(item.contextValue).toBe('isPersistentQuery'); + }); + + it('renders an object leaf via the shared panel renderer', () => { + const leaf: PersistentQueryTreeNode = [ + WORKER_URL, + { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, + ] as [URL, VariableDefintion]; + const item = provider.getTreeItem(leaf); + expect(item.label).toBe('my_table'); + expect(item.command?.command).toBe(OPEN_VARIABLE_PANELS_CMD); + }); + }); +}); diff --git a/src/providers/PersistentQueryTreeProvider.ts b/src/providers/PersistentQueryTreeProvider.ts new file mode 100644 index 000000000..d8221386a --- /dev/null +++ b/src/providers/PersistentQueryTreeProvider.ts @@ -0,0 +1,286 @@ +import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import { INTERACTIVE_CONSOLE_QUERY_TYPE } from '../common'; +import type { + IAsyncCacheService, + IDheService, + IServerManager, + PersistentQueryNode, + PersistentQueryTreeNode, +} from '../types'; +import { ServerTreeProviderBase } from './ServerTreeProviderBase'; +import { + getConnectionServerLabel, + getPanelVariableTreeItem, + getPersistentQueryObjectLeaves, + getPersistentQueryServerTreeItem, + getPersistentQueryTreeItem, + isPersistentQueryNode, + Logger, +} from '../util'; +import { + QueryConfigTableService, + type QueryInfoTableSubscription, +} from '../services'; + +const logger = new Logger('PersistentQueryTreeProvider'); + +/** + * Tree data provider backing the **Persistent Queries** view. Lists ACL-visible + * non-InteractiveConsole persistent queries (from the ticking `QueryInfo` table) + * grouped under their DHE server, each expandable to its exported objects. + * Selecting an object opens it read-only via `OPEN_VARIABLE_PANELS_CMD` — the + * same panel path the Panels tree uses. Browse-only: no console session, no + * "attach", and the server-side PQ is never deleted. + * + * Tree shape (mirrors `ServerConnectionPanelTreeProvider`): + * root → DHE `ServerState[]` + * server → `PersistentQueryNode[]` (filtered `QueryInfo` from the table) + * PQ → `[URL, VariableDefintion][]` (object leaves, opened on click) + */ +export class PersistentQueryTreeProvider extends ServerTreeProviderBase { + constructor( + serverManager: IServerManager, + dheServiceCache: IAsyncCacheService, + dheJsApiCache: IAsyncCacheService + ) { + super(serverManager); + this._dheServiceCache = dheServiceCache; + this._dheJsApiCache = dheJsApiCache; + + // Tear down per-server table subscriptions for any server that goes away + // (disconnect / config change removing the server). + this.disposables.add( + this.serverManager.onDidDisconnect(url => { + this._disposeServer(url); + }) + ); + } + + private readonly _dheServiceCache: IAsyncCacheService; + private readonly _dheJsApiCache: IAsyncCacheService; + + /** One `QueryConfigTableService` per DHE server URL. */ + private readonly _tableServiceMap = new Map(); + /** The ticking `QueryInfo` table subscription per DHE server URL. */ + private readonly _subscriptionMap = new Map< + string, + Promise + >(); + + /** + * Get (creating if needed) the ticking `QueryInfo` table subscription for a + * DHE server, filtered to running non-InteractiveConsole PQs. Refreshes the + * tree on every table tick. Cached per server. + * @param serverUrl The DHE server URL. + */ + private _getSubscription = ( + serverUrl: URL + ): Promise => { + const key = serverUrl.toString(); + + let subscriptionPromise = this._subscriptionMap.get(key); + if (subscriptionPromise != null) { + return subscriptionPromise; + } + + subscriptionPromise = (async (): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + + let tableService = this._tableServiceMap.get(key); + if (tableService == null) { + tableService = new QueryConfigTableService( + serverUrl, + dheService, + this._dheJsApiCache + ); + this._tableServiceMap.set(key, tableService); + this.disposables.add(tableService); + } + + // Server-side filter: running PQs, excluding InteractiveConsole + other + // helper/system query types (`excludeHelperTypes`). See Decision 3. + const subscription = await tableService.getQueryInfoTable({ + statuses: ['Running'], + excludeHelperTypes: true, + }); + + // Refresh the tree whenever the filtered PQ set ticks. + this.disposables.add( + subscription.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }) + ); + + return subscription; + })(); + + this._subscriptionMap.set(key, subscriptionPromise); + + // If the table fails to load (e.g. WebClientData unavailable), drop the + // cached rejection so a later refresh can retry. + subscriptionPromise.catch(err => { + logger.error( + `Failed to load QueryInfo table for ${serverUrl}:`, + err + ); + if (this._subscriptionMap.get(key) === subscriptionPromise) { + this._subscriptionMap.delete(key); + } + }); + + return subscriptionPromise; + }; + + /** + * Dispose the table subscription + service for a server (on disconnect). + * @param serverUrl The DHE server URL. + */ + private _disposeServer = (serverUrl: URL): void => { + const key = serverUrl.toString(); + + const subscriptionPromise = this._subscriptionMap.get(key); + this._subscriptionMap.delete(key); + if (subscriptionPromise != null) { + void subscriptionPromise + .then(subscription => subscription.dispose()) + .catch(() => { + // Ignore — already logged / disposed. + }); + } + + const tableService = this._tableServiceMap.get(key); + this._tableServiceMap.delete(key); + if (tableService != null) { + this.disposables.delete(tableService); + void tableService.dispose(); + } + + this._onDidChangeTreeData.fire(); + }; + + /** + * Read the filtered set of running non-IC PQ serials from the table + * subscription, then resolve each to a full `QueryInfo` via + * `getKnownConfigs()` (Gotcha 5 — the table rows lack `designated`). Excludes + * child-replica rows (`parentId != null`) and any InteractiveConsole type + * that slipped through (Gotcha 4 — defense in depth). + * @param serverUrl The DHE server URL. + */ + private _getPersistentQueries = async ( + serverUrl: URL + ): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + const dheClient = await dheService.getClient(false); + if (dheClient == null) { + return []; + } + + let subscription: QueryInfoTableSubscription; + try { + subscription = await this._getSubscription(serverUrl); + } catch { + // Error already logged in `_getSubscription`; show an empty PQ list. + return []; + } + + const { table } = subscription; + + // Read the visible (filtered) serials from the table viewport, excluding + // child-replica rows (`Parent` set) — see Decision 3 / Gotcha 4. + const serialColumn = table.findColumn('Serial'); + const parentColumn = table.findColumn('Parent'); + const viewportData = await table.getViewportData(); + + const filteredSerials = new Set(); + for (const row of viewportData.rows) { + const parent = row.get(parentColumn); + if (parent != null && String(parent).length > 0) { + continue; + } + filteredSerials.add(String(row.get(serialColumn))); + } + + // Resolve serial → full QueryInfo (carries name/owner/status/objects). + return dheClient.client + .getKnownConfigs() + .filter( + queryInfo => + queryInfo.serial != null && + filteredSerials.has(String(queryInfo.serial)) && + // Defense in depth: never list InteractiveConsole workers here (they + // live in the WORKERS tree). + queryInfo.type !== INTERACTIVE_CONSOLE_QUERY_TYPE + ); + }; + + getTreeItem = ( + node: PersistentQueryTreeNode + ): vscode.TreeItem => { + // Object leaf node (worker URL + variable). Reuse the Panels tree renderer + // so the click command is `OPEN_VARIABLE_PANELS_CMD` verbatim. + if (Array.isArray(node)) { + return getPanelVariableTreeItem(node); + } + + // Persistent-query node. + if (isPersistentQueryNode(node)) { + return getPersistentQueryTreeItem(node); + } + + // DHE server node grouping its persistent queries. + return getPersistentQueryServerTreeItem(node); + }; + + getChildren = async ( + elementOrRoot?: PersistentQueryTreeNode + ): Promise => { + // Root: one node per connected DHE server. + if (elementOrRoot == null) { + return this.serverManager + .getServers({ type: 'DHE' }) + .filter(server => server.isConnected) + .sort((a, b) => + getConnectionServerLabel(a).localeCompare(getConnectionServerLabel(b)) + ); + } + + // Object leaves have no children. + if (Array.isArray(elementOrRoot)) { + return []; + } + + // PQ node → its exported object leaves. Objects come straight off the + // running PQ's `designated.objects` (no console session, no connect-on- + // expand needed — the QueryInfo already carries them). The worker URL is + // registered as a browse connection so the embed panel can authenticate. + if (isPersistentQueryNode(elementOrRoot)) { + const { dheServerUrl, queryInfo } = elementOrRoot; + + const workerInfo = await this.serverManager.registerBrowseConnection( + dheServerUrl, + queryInfo + ); + + if (workerInfo == null) { + return []; + } + + return getPersistentQueryObjectLeaves( + new URL(workerInfo.workerUrl), + queryInfo + ); + } + + // Server node → its filtered persistent queries. + const serverUrl = elementOrRoot.url; + const queries = await this._getPersistentQueries(serverUrl); + + return queries + .sort((a, b) => a.name.localeCompare(b.name)) + .map( + (queryInfo): PersistentQueryNode => ({ dheServerUrl: serverUrl, queryInfo }) + ); + }; +} diff --git a/src/providers/index.ts b/src/providers/index.ts index 7846bf92c..a5cf2d60d 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ export * from './CreateQueryViewProvider'; export * from './McpServerDefinitionProvider'; +export * from './PersistentQueryTreeProvider'; export * from './RemoteImportSourceTreeProvider'; export * from './RunCommandCodeLensProvider'; export * from './RunMarkdownCodeBlockCodeLensProvider'; diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 093ea2f36..7932832d7 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -626,6 +626,87 @@ export class ServerManager implements IServerManager { await this.disconnectFromServer(workerUrl); }; + /** + * Register a lightweight, NON-console "browse" connection for a persistent + * query's worker so the DH embed panel (`OPEN_VARIABLE_PANELS_CMD`) can open + * its objects read-only. This wires up exactly the three lookups the panel + * path needs — `getConnection`, `getWorkerInfo`, `getWorkerCredentials` — all + * keyed by the worker URL, WITHOUT creating a console session. + * + * Browse connections are a view layer over someone else's PQ. They are + * deliberately NOT worker connections: no `initSession`, no + * `connectionCount` increment, no `_attachedWorkerSerials` entry, and the + * server-side PQ is NEVER deleted on teardown (`unregisterBrowseConnection`). + * Idempotent — re-registering the same worker URL is a no-op that returns the + * existing worker info. + * @param dheServerUrl The DHE server the PQ belongs to (for auth resolution). + * @param queryInfo The running PQ whose worker to register. + * @returns The registered `WorkerInfo`, or `null` if the DHE service/worker + * info could not be resolved. + */ + registerBrowseConnection = async ( + dheServerUrl: URL, + queryInfo: QueryInfo + ): Promise => { + if (!this._dheServiceCache.has(dheServerUrl)) { + return null; + } + + const dheService = await this._dheServiceCache.get(dheServerUrl); + + let workerInfo: WorkerInfo; + try { + workerInfo = dheService.registerWorkerInfo(queryInfo); + } catch (err) { + logger.error( + 'Failed to register browse worker info:', + queryInfo.serial, + err + ); + return null; + } + + const workerUrl = new URL(workerInfo.workerUrl); + + // Map the worker URL to its DHE server so the panel auth flow + // (`getWorkerCredentials` / `getWorkerInfo`) can resolve creds + info. + this._workerURLToServerURLMap.set(workerUrl, dheServerUrl); + + // Register a lightweight non-console ConnectionState so + // `PanelController._onRefreshPanelsContent`'s `getConnection` assertion is + // satisfied. This is NOT a `DhcService` (so no PSK path, no session) and is + // never counted as a worker connection. + if (!this._connectionMap.has(workerUrl)) { + this._connectionMap.set(workerUrl, { + label: workerInfo.name, + isConnected: true, + isRunningCode: false, + serverUrl: workerUrl, + }); + this._onDidUpdate.fire(); + } + + return workerInfo; + }; + + /** + * Tear down a browse connection previously registered with + * `registerBrowseConnection`. Removes the lightweight connection + auth + * mapping ONLY — it never deletes the server-side PQ and never touches + * `connectionCount` / `_attachedWorkerSerials` (browse connections never + * contributed to those). No-op if the worker URL is not a browse connection. + * @param workerUrl The worker URL to unregister. + */ + unregisterBrowseConnection = (workerUrl: URL): void => { + const hadConnection = this._connectionMap.has(workerUrl); + this._connectionMap.delete(workerUrl); + this._workerURLToServerURLMap.delete(workerUrl); + + if (hadConnection) { + this._onDidUpdate.fire(); + } + }; + /** * Add a placeholder connection to represent a pending DHE Core+ woker creation. * @param label The connection label to show in the UI for this pending worker. diff --git a/src/services/index.ts b/src/services/index.ts index c24d34584..671a5a960 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -8,6 +8,7 @@ export * from './FilteredWorkspace'; export * from './PanelService'; export * from './ParseDocumentCache'; export * from './PollingService'; +export * from './QueryConfigTableService'; export * from './RemoteFileSourceService'; export * from './SecretService'; export * from './ServerManager'; diff --git a/src/testUtils.ts b/src/testUtils.ts index 3f03829c6..2ec96fd80 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -40,6 +40,37 @@ export function getLastEventListener< return calls.at(-1)![1]; } +/** + * Build a mock `QueryInfoTableSubscription` whose `table.getViewportData()` + * returns rows backed by the given plain objects, keyed by column name. Each + * `row.get(column)` reads `row[column.name]`. Used to test consumers of the + * ticking `QueryInfo` table without a live server. + * @param options.rows Row records keyed by column name. + * @returns A mocked subscription (only `table` + `onDidUpdate` are populated). + */ +export function getQueryInfoTableMock({ + rows, +}: { + rows: Record[]; +}): unknown { + const findColumn = (name: string): { name: string } => ({ name }); + + const viewportRows = rows.map(record => ({ + get: (column: { name: string }): unknown => record[column.name], + })); + + const table = { + findColumn, + getViewportData: vi.fn(async () => ({ rows: viewportRows })), + }; + + return { + table, + onDidUpdate: vi.fn(() => () => {}), + dispose: vi.fn(async () => {}), + }; +} + /** * Return last registered event handler for 'message' event. Assumes that * `window.addEventListener` has been mocked or spied on. diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index ecce490c0..e2daf3130 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -216,6 +216,18 @@ export interface IServerManager extends IDisposable { serverOrWorkerUrl: URL | WorkerURL ) => Promise; getWorkerInfo: (maybeWorkerUrl: URL) => Promise; + /** + * Register a lightweight, non-console "browse" connection for a persistent + * query's worker so the DH embed panel can open its objects read-only. Never + * creates a console session, never increments `connectionCount`, and never + * deletes the server-side PQ. + */ + registerBrowseConnection: ( + dheServerUrl: URL, + queryInfo: QueryInfo + ) => Promise; + /** Tear down a browse connection (never deletes the server-side PQ). */ + unregisterBrowseConnection: (workerUrl: URL) => void; setEditorConnection: ( uri: vscode.Uri, languageId: string, diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index dd31e1a49..bc768b1e8 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConnectionState, ServerState, @@ -21,3 +22,28 @@ export type ServerConnectionPanelNode = export interface ServerConnectionPanelTreeView extends vscode.TreeView {} + +/** + * A persistent-query node in the Persistent Queries tree. Pairs the owning DHE + * server URL with the `QueryInfo` so the provider can resolve the PQ's worker + * (via `getKnownConfigs()`) and its exported objects on expand. + */ +export type PersistentQueryNode = { + readonly dheServerUrl: URL; + readonly queryInfo: QueryInfo; +}; + +/** + * A node in the Persistent Queries tree: + * - `ServerState`: a DHE server grouping its persistent queries. + * - `PersistentQueryNode`: a non-InteractiveConsole PQ (expandable to objects). + * - `[URL, VariableDefintion]`: an exported object leaf (worker URL + variable), + * rendered/opened exactly like the Panels tree's object leaves. + */ +export type PersistentQueryTreeNode = + | ServerState + | PersistentQueryNode + | [URL, VariableDefintion]; + +export interface PersistentQueryTreeView + extends vscode.TreeView {} diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index b294c0fc9..3276a751d 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -4,6 +4,11 @@ import { getConnectionServerTreeItem, getPanelConnectionTreeItem, getPanelVariableTreeItem, + getPersistentQueryIconId, + getPersistentQueryObjectLeaves, + getPersistentQueryServerTreeItem, + getPersistentQueryTreeItem, + isPersistentQueryNode, getServerContextValue, getServerDescription, getServerGroupContextValue, @@ -247,3 +252,109 @@ describe('groupServers', () => { expect(actual).toMatchSnapshot(); }); }); + +describe('getPersistentQueryIconId', () => { + it('returns the connected icon for a Running PQ', () => { + expect(getPersistentQueryIconId('Running')).toBe('circle-large-filled'); + }); + + it.each([['Stopped'], ['Initializing'], [null], [undefined]])( + 'returns the connecting/spinner icon for non-Running status: %s', + status => { + expect(getPersistentQueryIconId(status as string | null)).toBe( + 'sync~spin' + ); + } + ); +}); + +describe('isPersistentQueryNode', () => { + it('is true for a node carrying queryInfo', () => { + const node = { + dheServerUrl: new URL('https://dhe.example.com/'), + queryInfo: { name: 'PQ' }, + } as unknown as Parameters[0]; + expect(isPersistentQueryNode(node)).toBe(true); + }); + + it('is false for a server state node', () => { + const server = { url: new URL('https://dhe.example.com/') } as ServerState; + expect(isPersistentQueryNode(server)).toBe(false); + }); +}); + +describe('getPersistentQueryTreeItem', () => { + it('renders name + owner + collapsed state + context value', () => { + const node = { + dheServerUrl: new URL('https://dhe.example.com/'), + queryInfo: { + name: 'My PQ', + owner: 'alice', + designated: { status: 'Running' }, + }, + } as unknown as Parameters[0]; + + const item = getPersistentQueryTreeItem(node); + expect(item.label).toBe('My PQ'); + expect(item.description).toBe('alice'); + expect(item.contextValue).toBe('isPersistentQuery'); + // Collapsed = 1 in the vscode mock enum. + expect(item.collapsibleState).toBe(1); + }); +}); + +describe('getPersistentQueryServerTreeItem', () => { + it('renders an expanded server grouping node', () => { + const server = { + type: 'DHE', + url: new URL('https://dhe.example.com/'), + label: 'DHE', + } as ServerState; + const item = getPersistentQueryServerTreeItem(server); + expect(item.label).toBe('DHE'); + expect(item.contextValue).toBe('isPersistentQueryServer'); + // Expanded = 2 in the vscode mock enum. + expect(item.collapsibleState).toBe(2); + }); +}); + +describe('getPersistentQueryObjectLeaves', () => { + const workerUrl = new URL('https://dhe.example.com/worker/1/'); + + it('maps designated.objects to [url, variable] leaves', () => { + const queryInfo = { + designated: { + objects: [ + { title: 't', name: 't', type: 'Table', id: 'v1' }, + { title: 'f', name: 'f', type: 'Figure', id: 'v2' }, + ], + }, + } as unknown as Parameters[1]; + + const leaves = getPersistentQueryObjectLeaves(workerUrl, queryInfo); + expect(leaves).toHaveLength(2); + expect(leaves.map(([, v]) => v.title)).toEqual(['t', 'f']); + leaves.forEach(([url]) => expect(url).toBe(workerUrl)); + }); + + it('returns an empty array when there are no objects', () => { + const queryInfo = { + designated: { objects: [] }, + } as unknown as Parameters[1]; + expect(getPersistentQueryObjectLeaves(workerUrl, queryInfo)).toEqual([]); + }); + + it('filters out untitled / untyped objects defensively', () => { + const queryInfo = { + designated: { + objects: [ + { title: 't', name: 't', type: 'Table', id: 'v1' }, + { title: null, name: 'x', type: 'Table', id: 'v2' }, + { title: 'y', name: 'y', type: null, id: 'v3' }, + ], + }, + } as unknown as Parameters[1]; + const leaves = getPersistentQueryObjectLeaves(workerUrl, queryInfo); + expect(leaves).toHaveLength(1); + }); +}); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 36dffc4b8..4ded34870 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -1,9 +1,11 @@ import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import type { ConnectionState, ConsoleType, IServerManager, NonEmptyArray, + PersistentQueryNode, ServerGroupState, ServerState, VariableDefintion, @@ -14,6 +16,7 @@ import { DH_PROTECTED_VARIABLE_NAMES, ICON_ID, OPEN_VARIABLE_PANELS_CMD, + PERSISTENT_QUERY_TREE_ITEM_CONTEXT, SERVER_TREE_ITEM_CONTEXT, type ServerTreeItemContextValue, } from '../common'; @@ -123,6 +126,86 @@ export function getPanelVariableTreeItem([url, variable]: [ }; } +/** + * Get the icon for a persistent query based on its (designated worker) status. + * `Running` PQs — the only ones this tree lists — show the connected icon; any + * transitional/other status shows a spinner. Mirrors the connection tree's + * running/connecting icon convention. + * @param status The `queryInfo.designated?.status` value. + */ +export function getPersistentQueryIconId(status: string | null | undefined): string { + return status === 'Running' ? ICON_ID.serverConnected : ICON_ID.connecting; +} + +/** + * Get `TreeItem` for a persistent-query node in the Persistent Queries tree. + * The node is collapsible (its children are the PQ's exported objects) and + * carries a status icon + the PQ name. + * @param node The persistent-query node. + */ +export function getPersistentQueryTreeItem( + node: PersistentQueryNode +): vscode.TreeItem { + const { queryInfo } = node; + const status = queryInfo.designated?.status; + + return { + label: queryInfo.name, + description: queryInfo.owner ?? undefined, + tooltip: `${queryInfo.name}${status == null ? '' : ` (${status})`}`, + iconPath: new vscode.ThemeIcon(getPersistentQueryIconId(status)), + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQuery, + }; +} + +/** + * Get `TreeItem` for a DHE server node in the Persistent Queries tree. This is a + * grouping container whose children are the server's (non-InteractiveConsole) + * persistent queries. + * @param server DHE server state. + */ +export function getPersistentQueryServerTreeItem( + server: ServerState +): vscode.TreeItem { + return { + label: getConnectionServerLabel(server), + iconPath: new vscode.ThemeIcon(ICON_ID.connected), + collapsibleState: vscode.TreeItemCollapsibleState.Expanded, + contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQueryServer, + }; +} + +/** + * Type guard distinguishing a persistent-query node from a server node in the + * Persistent Queries tree. A `PersistentQueryNode` carries a `queryInfo`. + * @param node The node to check. + */ +export function isPersistentQueryNode( + node: ServerState | PersistentQueryNode +): node is PersistentQueryNode { + return (node as PersistentQueryNode).queryInfo != null; +} + +/** + * Map a `QueryInfo`'s exported objects (`designated.objects`) to + * `VariableDefintion` leaves paired with the given worker URL — the same + * `[URL, VariableDefintion]` shape the Panels tree uses for object leaves. + * Filters out unnamed / untyped entries defensively. + * @param workerUrl The worker URL objects are hosted on (for the open command). + * @param queryInfo The running PQ whose objects to enumerate. + */ +export function getPersistentQueryObjectLeaves( + workerUrl: URL, + queryInfo: QueryInfo +): [URL, VariableDefintion][] { + const objects = queryInfo.designated?.objects ?? []; + + return objects + .filter(obj => obj.title != null && obj.type != null) + .map(obj => [workerUrl, obj as VariableDefintion]); +} + /** * Type guard for a (DHE) server node within the connection / panel tree root. * `ServerState` carries `url`; `ConnectionState` carries `serverUrl`. From 96ca089c91944ec93c3c2671f1c592d0627801e5 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 28 Jul 2026 17:35:37 -0500 Subject: [PATCH 28/68] Reorder PQ view + stop auto-creating workers on plain server connect (#DH-22593) Move the "Persistent Queries" view to sit directly after "Workers" (before "Panels"/"Remote Import Sources") in the list view container. Plain "connect to server" (server node / operate-as) no longer auto-creates a DHE worker when none are attachable: it connects and attaches to existing IC workers so persistent queries can populate without provisioning a worker. Worker creation stays explicit (the "+" action) or on-demand via the run-code editor flow. Threaded through a `createWorkerIfNone` flag on `connectToServer`/`_createOrAttachToWorkers` (defaults to true to preserve the run-code and managed-server paths); `onConnectToServer` passes false. Co-Authored-By: Claude Opus 4.8 (1M context) (#DH-22593) --- package.json | 8 ++--- src/controllers/ConnectionController.ts | 6 +++- src/services/ServerManager.spec.ts | 46 +++++++++++++++++++++++++ src/services/ServerManager.ts | 34 +++++++++++++----- src/types/serviceTypes.d.ts | 3 +- 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 242a836e1..5ca57db76 100644 --- a/package.json +++ b/package.json @@ -1140,14 +1140,14 @@ "icon": "images/dh-logo-28.svg" }, { - "id": "vscode-deephaven.view.serverConnectionPanelTree", - "name": "Panels", + "id": "vscode-deephaven.view.persistentQueryTree", + "name": "Persistent Queries", "type": "tree", "icon": "images/dh-logo-28.svg" }, { - "id": "vscode-deephaven.view.persistentQueryTree", - "name": "Persistent Queries", + "id": "vscode-deephaven.view.serverConnectionPanelTree", + "name": "Panels", "type": "tree", "icon": "images/dh-logo-28.svg" }, diff --git a/src/controllers/ConnectionController.ts b/src/controllers/ConnectionController.ts index 514fcc221..05a314c29 100644 --- a/src/controllers/ConnectionController.ts +++ b/src/controllers/ConnectionController.ts @@ -343,10 +343,14 @@ export class ConnectionController const workerConsoleType = serverState.type === 'DHE' ? getConsoleType(languageId) : undefined; + // Plain "connect to server" — attach to existing workers and let + // persistent queries populate, but do not auto-create a worker. Worker + // creation is explicit (the "+" action) or on-demand when running code. await this._serverManager?.connectToServer( serverState.url, workerConsoleType, - operateAsAnotherUser + operateAsAnotherUser, + false ); }; diff --git a/src/services/ServerManager.spec.ts b/src/services/ServerManager.spec.ts index 3205f41d0..d8e933143 100644 --- a/src/services/ServerManager.spec.ts +++ b/src/services/ServerManager.spec.ts @@ -44,6 +44,12 @@ type TestServerManager = PublicOf & { get: ReturnType; }; _resolvePendingServerConnection: (serverUrl: URL) => void; + _createOrAttachToWorkers: ( + dheService: IDheService, + workerConsoleType?: unknown, + createWorkerIfNone?: boolean + ) => Promise; + _createWorker: ReturnType; }; /** Build a `ServerManager` with minimal mocked dependencies. */ @@ -185,6 +191,46 @@ describe('ServerManager.connectToServer', () => { }); }); +describe('ServerManager._createOrAttachToWorkers', () => { + let manager: TestServerManager; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createServerManager(); + // Isolate the create-vs-attach decision from the placeholder/attach + // plumbing. + manager._createWorker = vi.fn().mockResolvedValue(null); + }); + + function mockDheServiceWithNoWorkers(): IDheService { + return { + listAttachableWorkers: vi.fn().mockResolvedValue([]), + registerWorkerInfo: vi.fn(), + } as unknown as IDheService; + } + + it('does not create a worker when none are attachable and createWorkerIfNone is false', async () => { + const dheService = mockDheServiceWithNoWorkers(); + + const result = await manager._createOrAttachToWorkers( + dheService, + undefined, + false + ); + + expect(result).toEqual([]); + expect(manager._createWorker).not.toHaveBeenCalled(); + }); + + it('creates a worker when none are attachable and createWorkerIfNone defaults to true', async () => { + const dheService = mockDheServiceWithNoWorkers(); + + await manager._createOrAttachToWorkers(dheService); + + expect(manager._createWorker).toHaveBeenCalledTimes(1); + }); +}); + describe('ServerManager.isServerConnecting', () => { let manager: TestServerManager; diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 7932832d7..f9161f6db 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -233,18 +233,23 @@ export class ServerManager implements IServerManager { /** * Connect to a server and attach to any Core / Core+ workers available for - * the server. For DHE, if no workers are available, creates one and attaches - * to it. + * the server. For DHE, if no workers are available and `createWorkerIfNone` + * is `true`, creates one and attaches to it; if `false`, the server connects + * (letting persistent queries populate) without provisioning a worker. * @param serverUrl * @param workerConsoleType * @param operateAsAnotherUser + * @param createWorkerIfNone When no attachable DHE worker exists, whether to + * auto-create one. Defaults to `true` (e.g. the run-code flow needs a live + * console). The plain "connect to server" action passes `false`. * @returns The connection state of the attached worker, or `null` if the - * connection or worker creation/attachment failed. + * connection or worker creation/attachment failed (or none was created). */ connectToServer = async ( serverUrl: URL, workerConsoleType?: ConsoleType, - operateAsAnotherUser: boolean = false + operateAsAnotherUser: boolean = false, + createWorkerIfNone: boolean = true ): Promise => { const serverState = this._serverMap.get(serverUrl); @@ -266,14 +271,16 @@ export class ServerManager implements IServerManager { return this._doConnectToServer( serverState, workerConsoleType, - operateAsAnotherUser + operateAsAnotherUser, + createWorkerIfNone ); }; private _doConnectToServer = async ( serverState: ServerState, workerConsoleType: ConsoleType | undefined = undefined, - operateAsAnotherUser: boolean + operateAsAnotherUser: boolean, + createWorkerIfNone: boolean ): Promise => { const serverUrl = serverState.url; @@ -307,7 +314,8 @@ export class ServerManager implements IServerManager { [firstConnection = null] = await this._createOrAttachToWorkers( dheService, - workerConsoleType + workerConsoleType, + createWorkerIfNone ); return firstConnection; @@ -448,7 +456,8 @@ export class ServerManager implements IServerManager { private _createOrAttachToWorkers = async ( dheService: IDheService, - workerConsoleType: ConsoleType | undefined = undefined + workerConsoleType: ConsoleType | undefined = undefined, + createWorkerIfNone: boolean = true ): Promise => { const attachableWorkers = await dheService.listAttachableWorkers( this._attachedWorkerSerials.keys() @@ -456,8 +465,15 @@ export class ServerManager implements IServerManager { let workerInfos: [boolean, WorkerInfo][]; - // If no attachable workers exist, create a new one + // If no attachable workers exist, optionally create a new one. When + // `createWorkerIfNone` is false (the plain "connect to server" action), the + // server stays connected with no worker so persistent queries can populate + // without forcing worker creation. if (attachableWorkers.length === 0) { + if (!createWorkerIfNone) { + return []; + } + const workerInfo = await this._createWorker( dheService, workerConsoleType diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index e2daf3130..892cc2465 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -183,7 +183,8 @@ export interface IServerManager extends IDisposable { connectToServer: ( serverUrl: URL, workerConsoleType?: ConsoleType, - operateAsAnotherUser?: boolean + operateAsAnotherUser?: boolean, + createWorkerIfNone?: boolean ) => Promise; createWorker: ( dheServerUrl: URL, From 6d66b13c1d29fd92dd36df2e133e96d0e5728bd5 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 13 Aug 2026 16:38:54 -0500 Subject: [PATCH 29/68] persistent queries become browsable from the Panels tree, and only variables that can actually render get a panel. (#DH-22593) --- scripts/validate-pq-explorer.mjs | 120 ++++-- src/common/constants.ts | 47 +++ src/controllers/ExtensionController.ts | 19 +- .../PersistentQueryTreeProvider.spec.ts | 131 ++----- src/providers/PersistentQueryTreeProvider.ts | 230 ++---------- .../ServerConnectionPanelTreeProvider.spec.ts | 256 +++++++++++++ .../ServerConnectionPanelTreeProvider.ts | 105 +++++- src/providers/ServerConnectionTreeProvider.ts | 9 +- src/services/DhcService.ts | 8 +- src/services/DheService.ts | 3 +- src/services/PersistentQueryService.spec.ts | 137 +++++++ src/services/PersistentQueryService.ts | 217 +++++++++++ src/services/QueryConfigTableService.spec.ts | 13 +- src/services/QueryConfigTableService.ts | 135 +++++-- src/services/ServerManager.ts | 26 +- src/services/index.ts | 1 + src/testUtils.ts | 29 +- src/types/commonTypes.d.ts | 9 + src/types/serviceTypes.d.ts | 11 + src/types/treeViewTypes.d.ts | 8 + .../__snapshots__/treeViewUtils.spec.ts.snap | 2 +- src/util/panelUtils.spec.ts | 51 +++ src/util/panelUtils.ts | 27 +- src/util/serverUtils.spec.ts | 24 ++ src/util/serverUtils.ts | 13 + src/util/treeViewUtils.spec.ts | 343 +++++++++++++++++- src/util/treeViewUtils.ts | 218 +++++++++-- 27 files changed, 1734 insertions(+), 458 deletions(-) create mode 100644 src/providers/ServerConnectionPanelTreeProvider.spec.ts create mode 100644 src/services/PersistentQueryService.spec.ts create mode 100644 src/services/PersistentQueryService.ts create mode 100644 src/util/panelUtils.spec.ts diff --git a/scripts/validate-pq-explorer.mjs b/scripts/validate-pq-explorer.mjs index 3493c9079..719d31320 100644 --- a/scripts/validate-pq-explorer.mjs +++ b/scripts/validate-pq-explorer.mjs @@ -6,8 +6,9 @@ * Enterprise) server we can: * 1. build a `CorePlusManager`, * 2. fetch the ticking `QueryInfo` table, - * 3. apply the PQ-explorer server-side filter (running, non-helper types), - * 4. print the filtered PQ rows, + * 3. apply the PQ-explorer server-side filter (non-helper types, any status), + * 4. print the filtered PQ rows from a full table subscription, and confirm + * every row's serial resolves to a known config, * 5. pick one running non-InteractiveConsole PQ and enumerate its exported * objects (from `queryInfo.designated.objects`, the same source the tree * provider uses — no console session / no `initSession`). @@ -79,6 +80,14 @@ async function main() { } console.log(`Fetching '${QUERY_CONFIG_TABLE}' table...`); + const webClientData = dheClient + .getKnownConfigs() + .find( + qi => + qi.name === WEB_CLIENT_DATA_CORE_QUERY && + qi.designated?.status === 'Running' + ); + const table = await makeFactoryServiceTablePromise({ client: dheClient, corePlusManager: manager, @@ -87,42 +96,108 @@ async function main() { tableName: QUERY_CONFIG_TABLE, }); - // Server-side filter: running, excluding helper/system types (which include - // InteractiveConsole). Mirrors the tree provider's filter (Decision 3). - const statusFilter = table - .findColumn(QueryColumns.STATUS.name) - .filter() - .in([dhe.FilterValue.ofString('Running')]); + // The table is created by the WebClientData worker's *community* API, so + // filter values must be built from that same API — a FilterValue from the + // enterprise `dhe` API throws java.lang.ClassCastException when the table + // casts it. `getApi` returns the cached instance the factory used above. + const coreApi = await manager.getApi( + webClientData.workerKind, + webClientData.designated.jsApiUrl + ); + + // Dump the actual runtime column types so we can see how Status/QueryType + // are really typed on the server (QueryColumns metadata is the new-table + // schema, not necessarily the runtime QueryInfo table type). + console.log('\nColumns (name: type):'); + for (const col of table.columns) { + console.log(` ${col.name}: ${col.type}`); + } + console.log(); + // Server-side filter: exclude InteractiveConsole + other helper/system + // types. Deliberately NOT filtered by status — stopped/failed PQs list too, + // mirroring `PersistentQueryService`. Filter values come from `coreApi` (the + // table's own API), not `dhe`. const excludeTypeFilter = table .findColumn(QueryColumns.QUERY_TYPE.name) .filter() - .in([...EXCLUDED_QUERY_TYPES].map(t => dhe.FilterValue.ofString(t))) + .in([...EXCLUDED_QUERY_TYPES].map(t => coreApi.FilterValue.ofString(t))) .not(); - table.applyFilter([statusFilter, excludeTypeFilter]); - table.setViewport(0, Math.max(0, table.size - 1)); + console.log( + `Unfiltered '${QUERY_CONFIG_TABLE}' size (pre-settle): ${table.size}` + ); - // Give the viewport a moment to populate. - await new Promise(resolve => setTimeout(resolve, 2000)); + // Poll until the (unfiltered) size settles, so we can distinguish "table + // never populates" (server-side/ACL/factory problem) from "viewport never + // covered the rows" (client-side bug). + for (let i = 0; i < 20 && table.size === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + } + console.log( + `Unfiltered '${QUERY_CONFIG_TABLE}' size (settled): ${table.size}` + ); + + table.applyFilter([excludeTypeFilter]); - const viewportData = await table.getViewportData(); const serialColumn = table.findColumn(QueryColumns.SERIAL.name); const nameColumn = table.findColumn(QueryColumns.NAME.name); + const statusColumn = table.findColumn(QueryColumns.STATUS.name); const parentColumn = table.findColumn(QueryColumns.PARENT_ID.name); - console.log(`\nFiltered running non-helper PQs (${viewportData.rows.length}):`); - const serials = []; - for (const row of viewportData.rows) { - const parent = row.get(parentColumn); - if (parent != null && String(parent).length > 0) { - continue; // skip child-replica rows + // Subscribe to the whole filtered table rather than tracking a viewport: + // `getViewportData()` only returns the snapshot that has arrived so far, so + // rows ticking in later were silently dropped. Mirrors + // `QueryConfigTableService`. + const tableSubscription = table.subscribe([ + serialColumn, + nameColumn, + statusColumn, + parentColumn, + ]); + + /** @type {{serial: string, name: string, status: string}[]} */ + let queries = []; + tableSubscription.addEventListener( + coreApi.Table.EVENT_UPDATED, + ({ detail }) => { + queries = detail.rows + .filter(row => { + const parent = row.get(parentColumn); + return parent == null || String(parent).length === 0; + }) + .map(row => ({ + serial: String(row.get(serialColumn)), + name: String(row.get(nameColumn)), + status: String(row.get(statusColumn)), + })); } - const serial = String(row.get(serialColumn)); + ); + + // Give the subscription a moment to deliver its first tick. + for (let i = 0; i < 20 && queries.length === 0; i++) { + await new Promise(resolve => setTimeout(resolve, 250)); + } + console.log(`Filtered '${QUERY_CONFIG_TABLE}' size: ${table.size}`); + + console.log(`\nFiltered non-helper PQs, any status (${queries.length}):`); + const serials = []; + for (const { serial, name, status } of queries) { serials.push(serial); - console.log(` - ${row.get(nameColumn)} [serial ${serial}]`); + console.log(` - ${name} [${status}] [serial ${serial}]`); } + // Every table serial must resolve to a known config — an unresolved serial + // is a PQ that would silently vanish from the tree. + const knownSerials = new Set( + dheClient.getKnownConfigs().map(qi => String(qi.serial)) + ); + const unresolved = serials.filter(serial => !knownSerials.has(serial)); + console.log( + `\nTable serials with no known config (expected 0): ${unresolved.length}` + + (unresolved.length > 0 ? ` — ${unresolved.join(', ')}` : '') + ); + // Resolve one PQ's full QueryInfo and enumerate its objects. const pq = dheClient .getKnownConfigs() @@ -146,6 +221,7 @@ async function main() { } } + tableSubscription.close(); table.close(); console.log('\nDone.'); } finally { diff --git a/src/common/constants.ts b/src/common/constants.ts index 1e10ae2ce..2fc49ca84 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -139,8 +139,55 @@ export const ICON_ID = { varElement: 'preview', varPandas: 'dh-pandas', varTable: 'dh-table', + /** + * Fallback icon for a worker node (connection / persistent query) whose script + * language isn't known. Deliberately not `connected` (`vm-connect`) — that's + * the parent server node's icon, and a worker should never look like its + * server. + */ + worker: 'remote', } as const; +/** + * Variable types that can open as a Deephaven panel. Anything not listed is + * hidden from panel lists rather than opening a panel that never renders. + * + * This is an allow-list because a worker's exported objects include plenty of + * things that are not panels at all — DHE service objects (`AclService` and + * friends), dashboards, and legacy widget types — and nothing on the object says + * so. The web UI decides by looking the type up in its widget-plugin registry + * (`WidgetLoaderPlugin.handlePanelOpen` silently returns when no plugin claims + * the type), a registry assembled at runtime from the plugins a given worker + * kind serves and therefore not inspectable from the extension host. + * + * The entries mirror the types the bundled web plugins claim — Grid + * (`Table`/`TreeTable`/`HierarchicalTable`/`PartitionedTable`), Chart (`Figure`), + * Pandas (`pandas.DataFrame`) — plus the two first-party plugin widgets + * (`deephaven.ui.Element`, `deephaven.plot.express.DeephavenFigure`). + * + * Deliberately absent: + * - `deephaven.ui.Dashboard`: a dashboard, not a panel (the web client excludes + * it from its own Panels menu — iris `QueryListUtils.filterPanelWidgets`). + * - `TableMap` / `Treemap`: legacy `dh.VariableType` members no plugin claims + * (`TableMap` was superseded by `PartitionedTable`). + * - `OtherWidget`: the server's catch-all for a widget with no specific type; + * no bundled plugin claims it. + * + * TRADEOFF: a server-side JS plugin defining its own widget type will be hidden + * until its type is added here. That is the cost of the allow-list — the + * alternative surfaces unopenable service objects. + */ +export const OPENABLE_PANEL_VARIABLE_TYPES: ReadonlySet = new Set([ + 'deephaven.plot.express.DeephavenFigure', + 'deephaven.ui.Element', + 'Figure', + 'HierarchicalTable', + 'pandas.DataFrame', + 'PartitionedTable', + 'Table', + 'TreeTable', +]); + /* eslint-disable @typescript-eslint/naming-convention */ export const VARIABLE_UNICODE_ICONS = { 'deephaven.plot.express.DeephavenFigure': '📈', diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 072275ad3..c7b2624a2 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -89,6 +89,7 @@ import { RemoteFileSourceService, PanelService, ParsedDocumentCache, + PersistentQueryService, PYTHON_FILE_PATTERN, SecretService, ServerManager, @@ -104,6 +105,7 @@ import type { IDhcService, IDhcServiceFactory, IPanelService, + IPersistentQueryService, ISecretService, IServerManager, IToastService, @@ -198,6 +200,7 @@ export class ExtensionController implements IDisposable { private _logFileHandler: LogFileHandler | null = null; private _panelController: PanelController | null = null; private _panelService: IPanelService | null = null; + private _persistentQueryService: IPersistentQueryService | null = null; private _pipServerController: PipServerController | null = null; private _dhcServiceFactory: IDhcServiceFactory | null = null; private _dheJsApiCache: IAsyncCacheService | null = null; @@ -743,6 +746,15 @@ export class ExtensionController implements IDisposable { ); this._context.subscriptions.push(this._serverManager); + // Shared PQ source for every view that lists persistent queries (the + // Persistent Queries tree and the Panels tree). + this._persistentQueryService = new PersistentQueryService( + this._serverManager, + this._dheServiceCache, + this._dheJsApiCache + ); + this._context.subscriptions.push(this._persistentQueryService); + this._serverManager.onDidDisconnect( serverUrl => { this._panelService?.clearServerData(serverUrl); @@ -936,10 +948,12 @@ export class ExtensionController implements IDisposable { ); // Connection Panel tree + assertDefined(this._persistentQueryService, 'persistentQueryService'); this._serverConnectionPanelTreeProvider = new ServerConnectionPanelTreeProvider( this._serverManager, - this._panelService + this._panelService, + this._persistentQueryService ); this._serverConnectionPanelTreeView = vscode.window.createTreeView( @@ -953,8 +967,7 @@ export class ExtensionController implements IDisposable { // Persistent Queries tree this._persistentQueryTreeProvider = new PersistentQueryTreeProvider( this._serverManager, - this._dheServiceCache, - this._dheJsApiCache + this._persistentQueryService ); this._persistentQueryTreeView = vscode.window.createTreeView( diff --git a/src/providers/PersistentQueryTreeProvider.spec.ts b/src/providers/PersistentQueryTreeProvider.spec.ts index 48f46133b..51bf8e3ff 100644 --- a/src/providers/PersistentQueryTreeProvider.spec.ts +++ b/src/providers/PersistentQueryTreeProvider.spec.ts @@ -1,10 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as vscode from 'vscode'; import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; import { PersistentQueryTreeProvider } from './PersistentQueryTreeProvider'; -import { getQueryInfoTableMock } from '../testUtils'; import type { - IAsyncCacheService, - IDheService, + IPersistentQueryService, IServerManager, PersistentQueryNode, PersistentQueryTreeNode, @@ -17,20 +16,6 @@ import { OPEN_VARIABLE_PANELS_CMD } from '../common'; // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); -// Control the QueryConfigTableService the provider constructs internally. -const getQueryInfoTable = vi.fn(); -vi.mock('../services', async importActual => { - const actual = await importActual(); - return { - ...actual, - // eslint-disable-next-line @typescript-eslint/naming-convention - QueryConfigTableService: class { - getQueryInfoTable = getQueryInfoTable; - dispose = vi.fn(async () => {}); - }, - }; -}); - const DHE_URL = new URL('https://dhe.example.com:8123/'); const WORKER_URL = new URL('https://dhe.example.com:8123/worker/1/'); @@ -43,6 +28,8 @@ function makeQueryInfo(overrides: Partial = {}): QueryInfo { type: 'DeephavenCommunity', designated: { status: 'Running', + jsApiUrl: `${WORKER_URL.href}jsapi/dh-core.js`, + ideUrl: `${WORKER_URL.href}ide`, objects: [ { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, { title: 'my_figure', name: 'my_figure', type: 'Figure', id: 'v2' }, @@ -66,34 +53,16 @@ function makeServerState(overrides: Partial = {}): ServerState { describe('PersistentQueryTreeProvider', () => { let serverManager: IServerManager; - let dheService: IDheService; - let dheServiceCache: IAsyncCacheService; - let dheJsApiCache: IAsyncCacheService; + let persistentQueryService: IPersistentQueryService; let provider: PersistentQueryTreeProvider; - let knownConfigs: QueryInfo[]; beforeEach(() => { vi.clearAllMocks(); - knownConfigs = [makeQueryInfo()]; - - dheService = { - getClient: vi.fn(async () => ({ - client: { - getKnownConfigs: vi.fn(() => knownConfigs), - }, - })), - } as unknown as IDheService; - - dheServiceCache = { - get: vi.fn(async () => dheService), - has: vi.fn(() => true), - } as unknown as IAsyncCacheService; - - dheJsApiCache = { - get: vi.fn(async () => ({}) as never), - has: vi.fn(() => true), - } as unknown as IAsyncCacheService; + persistentQueryService = { + onDidUpdate: vi.fn(() => vi.fn()), + getPersistentQueries: vi.fn(async () => [makeQueryInfo()]), + } as unknown as IPersistentQueryService; serverManager = { onDidUpdate: vi.fn(() => vi.fn()), @@ -107,8 +76,7 @@ describe('PersistentQueryTreeProvider', () => { provider = new PersistentQueryTreeProvider( serverManager, - dheServiceCache, - dheJsApiCache + persistentQueryService ); }); @@ -130,60 +98,38 @@ describe('PersistentQueryTreeProvider', () => { }); describe('getChildren (server -> persistent queries)', () => { - it('lists running non-IC PQs resolved from getKnownConfigs, excluding IC + replica children', async () => { - knownConfigs = [ - makeQueryInfo({ serial: 'serial-1', name: 'Zeta PQ' }), - makeQueryInfo({ serial: 'serial-2', name: 'Alpha PQ' }), - // InteractiveConsole — must be excluded. - makeQueryInfo({ serial: 'serial-ic', type: 'InteractiveConsole' }), - // Not in the (filtered) table viewport — excluded. - makeQueryInfo({ serial: 'serial-hidden', name: 'Hidden' }), - ]; - - getQueryInfoTable.mockResolvedValue( - getQueryInfoTableMock({ - // Serial column values in the filtered viewport; `serial-hidden` - // omitted, `serial-child` has a Parent set. - /* eslint-disable @typescript-eslint/naming-convention */ - rows: [ - { Serial: 'serial-1', Parent: null }, - { Serial: 'serial-2', Parent: null }, - { Serial: 'serial-ic', Parent: null }, - { Serial: 'serial-child', Parent: 'serial-1' }, - ], - /* eslint-enable @typescript-eslint/naming-convention */ - }) - ); + it('lists every PQ the service reports, including stopped ones', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([ + makeQueryInfo({ serial: 'serial-1', name: 'Alpha PQ' }), + makeQueryInfo({ + serial: 'serial-2', + name: 'Stopped PQ', + designated: undefined, + status: 'Stopped', + } as Partial), + ]); const server = makeServerState(); const children = (await provider.getChildren( server )) as PersistentQueryNode[]; - const names = children.map(c => c.queryInfo.name); - // Sorted by name; IC + hidden excluded. - expect(names).toEqual(['Alpha PQ', 'Zeta PQ']); - children.forEach(c => expect(c.dheServerUrl).toBe(server.url)); - }); - - it('applies a running + exclude-helper-types server-side filter', async () => { - getQueryInfoTable.mockResolvedValue( - getQueryInfoTableMock({ - // eslint-disable-next-line @typescript-eslint/naming-convention - rows: [{ Serial: 'serial-1', Parent: null }], - }) + expect(persistentQueryService.getPersistentQueries).toHaveBeenCalledWith( + server.url ); - - await provider.getChildren(makeServerState()); - - expect(getQueryInfoTable).toHaveBeenCalledWith({ - statuses: ['Running'], - excludeHelperTypes: true, - }); + expect(children.map(c => c.queryInfo.name)).toEqual([ + 'Alpha PQ', + 'Stopped PQ', + ]); + children.forEach(c => expect(c.dheServerUrl).toBe(server.url)); }); - it('returns an empty list when the table fails to load', async () => { - getQueryInfoTable.mockRejectedValue(new Error('WebClientData down')); + it('returns an empty list when the service reports none', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([]); const children = await provider.getChildren(makeServerState()); expect(children).toEqual([]); @@ -208,10 +154,7 @@ describe('PersistentQueryTreeProvider', () => { ); expect(leaves).toHaveLength(2); - expect(leaves.map(([, v]) => v.title)).toEqual([ - 'my_table', - 'my_figure', - ]); + expect(leaves.map(([, v]) => v.title)).toEqual(['my_table', 'my_figure']); // Leaves are keyed by the worker URL (from the browse connection). leaves.forEach(([url]) => expect(url.href).toBe(WORKER_URL.href)); @@ -242,7 +185,7 @@ describe('PersistentQueryTreeProvider', () => { expect(item.label).toBe('DHE'); }); - it('renders a persistent-query node with status icon + name', () => { + it('renders a persistent-query node with the status circle + name', () => { const node: PersistentQueryNode = { dheServerUrl: DHE_URL, queryInfo: makeQueryInfo({ name: 'My PQ' }), @@ -250,6 +193,10 @@ describe('PersistentQueryTreeProvider', () => { const item = provider.getTreeItem(node); expect(item.label).toBe('My PQ'); expect(item.contextValue).toBe('isPersistentQuery'); + // Status circle here, not the language icon the Panels tree uses. + expect((item.iconPath as vscode.ThemeIcon).id).toBe( + 'circle-large-filled' + ); }); it('renders an object leaf via the shared panel renderer', () => { diff --git a/src/providers/PersistentQueryTreeProvider.ts b/src/providers/PersistentQueryTreeProvider.ts index d8221386a..b59c99834 100644 --- a/src/providers/PersistentQueryTreeProvider.ts +++ b/src/providers/PersistentQueryTreeProvider.ts @@ -1,10 +1,6 @@ -import * as vscode from 'vscode'; -import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; -import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; -import { INTERACTIVE_CONSOLE_QUERY_TYPE } from '../common'; +import type * as vscode from 'vscode'; import type { - IAsyncCacheService, - IDheService, + IPersistentQueryService, IServerManager, PersistentQueryNode, PersistentQueryTreeNode, @@ -17,216 +13,52 @@ import { getPersistentQueryServerTreeItem, getPersistentQueryTreeItem, isPersistentQueryNode, - Logger, } from '../util'; -import { - QueryConfigTableService, - type QueryInfoTableSubscription, -} from '../services'; - -const logger = new Logger('PersistentQueryTreeProvider'); /** - * Tree data provider backing the **Persistent Queries** view. Lists ACL-visible - * non-InteractiveConsole persistent queries (from the ticking `QueryInfo` table) - * grouped under their DHE server, each expandable to its exported objects. - * Selecting an object opens it read-only via `OPEN_VARIABLE_PANELS_CMD` — the - * same panel path the Panels tree uses. Browse-only: no console session, no - * "attach", and the server-side PQ is never deleted. + * Tree data provider backing the **Persistent Queries** view. Lists the + * ACL-visible non-InteractiveConsole persistent queries of each connected DHE + * server (from the shared `IPersistentQueryService`), each expandable to its + * exported objects. Selecting an object opens it read-only via + * `OPEN_VARIABLE_PANELS_CMD` — the same panel path the Panels tree uses. + * Browse-only: no console session, no "attach", and the server-side PQ is never + * deleted. * * Tree shape (mirrors `ServerConnectionPanelTreeProvider`): * root → DHE `ServerState[]` - * server → `PersistentQueryNode[]` (filtered `QueryInfo` from the table) + * server → `PersistentQueryNode[]` * PQ → `[URL, VariableDefintion][]` (object leaves, opened on click) */ export class PersistentQueryTreeProvider extends ServerTreeProviderBase { constructor( serverManager: IServerManager, - dheServiceCache: IAsyncCacheService, - dheJsApiCache: IAsyncCacheService + persistentQueryService: IPersistentQueryService ) { super(serverManager); - this._dheServiceCache = dheServiceCache; - this._dheJsApiCache = dheJsApiCache; + this._persistentQueryService = persistentQueryService; - // Tear down per-server table subscriptions for any server that goes away - // (disconnect / config change removing the server). + // Refresh whenever the PQ set ticks. this.disposables.add( - this.serverManager.onDidDisconnect(url => { - this._disposeServer(url); + this._persistentQueryService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); }) ); } - private readonly _dheServiceCache: IAsyncCacheService; - private readonly _dheJsApiCache: IAsyncCacheService; - - /** One `QueryConfigTableService` per DHE server URL. */ - private readonly _tableServiceMap = new Map(); - /** The ticking `QueryInfo` table subscription per DHE server URL. */ - private readonly _subscriptionMap = new Map< - string, - Promise - >(); - - /** - * Get (creating if needed) the ticking `QueryInfo` table subscription for a - * DHE server, filtered to running non-InteractiveConsole PQs. Refreshes the - * tree on every table tick. Cached per server. - * @param serverUrl The DHE server URL. - */ - private _getSubscription = ( - serverUrl: URL - ): Promise => { - const key = serverUrl.toString(); - - let subscriptionPromise = this._subscriptionMap.get(key); - if (subscriptionPromise != null) { - return subscriptionPromise; - } - - subscriptionPromise = (async (): Promise => { - const dheService = await this._dheServiceCache.get(serverUrl); - - let tableService = this._tableServiceMap.get(key); - if (tableService == null) { - tableService = new QueryConfigTableService( - serverUrl, - dheService, - this._dheJsApiCache - ); - this._tableServiceMap.set(key, tableService); - this.disposables.add(tableService); - } - - // Server-side filter: running PQs, excluding InteractiveConsole + other - // helper/system query types (`excludeHelperTypes`). See Decision 3. - const subscription = await tableService.getQueryInfoTable({ - statuses: ['Running'], - excludeHelperTypes: true, - }); - - // Refresh the tree whenever the filtered PQ set ticks. - this.disposables.add( - subscription.onDidUpdate(() => { - this._onDidChangeTreeData.fire(); - }) - ); - - return subscription; - })(); - - this._subscriptionMap.set(key, subscriptionPromise); - - // If the table fails to load (e.g. WebClientData unavailable), drop the - // cached rejection so a later refresh can retry. - subscriptionPromise.catch(err => { - logger.error( - `Failed to load QueryInfo table for ${serverUrl}:`, - err - ); - if (this._subscriptionMap.get(key) === subscriptionPromise) { - this._subscriptionMap.delete(key); - } - }); - - return subscriptionPromise; - }; - - /** - * Dispose the table subscription + service for a server (on disconnect). - * @param serverUrl The DHE server URL. - */ - private _disposeServer = (serverUrl: URL): void => { - const key = serverUrl.toString(); - - const subscriptionPromise = this._subscriptionMap.get(key); - this._subscriptionMap.delete(key); - if (subscriptionPromise != null) { - void subscriptionPromise - .then(subscription => subscription.dispose()) - .catch(() => { - // Ignore — already logged / disposed. - }); - } - - const tableService = this._tableServiceMap.get(key); - this._tableServiceMap.delete(key); - if (tableService != null) { - this.disposables.delete(tableService); - void tableService.dispose(); - } - - this._onDidChangeTreeData.fire(); - }; - - /** - * Read the filtered set of running non-IC PQ serials from the table - * subscription, then resolve each to a full `QueryInfo` via - * `getKnownConfigs()` (Gotcha 5 — the table rows lack `designated`). Excludes - * child-replica rows (`parentId != null`) and any InteractiveConsole type - * that slipped through (Gotcha 4 — defense in depth). - * @param serverUrl The DHE server URL. - */ - private _getPersistentQueries = async ( - serverUrl: URL - ): Promise => { - const dheService = await this._dheServiceCache.get(serverUrl); - const dheClient = await dheService.getClient(false); - if (dheClient == null) { - return []; - } - - let subscription: QueryInfoTableSubscription; - try { - subscription = await this._getSubscription(serverUrl); - } catch { - // Error already logged in `_getSubscription`; show an empty PQ list. - return []; - } + private readonly _persistentQueryService: IPersistentQueryService; - const { table } = subscription; - - // Read the visible (filtered) serials from the table viewport, excluding - // child-replica rows (`Parent` set) — see Decision 3 / Gotcha 4. - const serialColumn = table.findColumn('Serial'); - const parentColumn = table.findColumn('Parent'); - const viewportData = await table.getViewportData(); - - const filteredSerials = new Set(); - for (const row of viewportData.rows) { - const parent = row.get(parentColumn); - if (parent != null && String(parent).length > 0) { - continue; - } - filteredSerials.add(String(row.get(serialColumn))); - } - - // Resolve serial → full QueryInfo (carries name/owner/status/objects). - return dheClient.client - .getKnownConfigs() - .filter( - queryInfo => - queryInfo.serial != null && - filteredSerials.has(String(queryInfo.serial)) && - // Defense in depth: never list InteractiveConsole workers here (they - // live in the WORKERS tree). - queryInfo.type !== INTERACTIVE_CONSOLE_QUERY_TYPE - ); - }; - - getTreeItem = ( - node: PersistentQueryTreeNode - ): vscode.TreeItem => { + getTreeItem = (node: PersistentQueryTreeNode): vscode.TreeItem => { // Object leaf node (worker URL + variable). Reuse the Panels tree renderer - // so the click command is `OPEN_VARIABLE_PANELS_CMD` verbatim. + // so the click command is `OPEN_VARIABLE_PANELS_CMD` verbatim. Browse-only: + // this view never deletes anything from a PQ. if (Array.isArray(node)) { - return getPanelVariableTreeItem(node); + return getPanelVariableTreeItem(node, false); } - // Persistent-query node. + // Persistent-query node. This view lists PQs of every status, so the node + // carries the Servers-tree status circle. if (isPersistentQueryNode(node)) { - return getPersistentQueryTreeItem(node); + return getPersistentQueryTreeItem(node, 'status'); } // DHE server node grouping its persistent queries. @@ -273,14 +105,16 @@ export class PersistentQueryTreeProvider extends ServerTreeProviderBase a.name.localeCompare(b.name)) - .map( - (queryInfo): PersistentQueryNode => ({ dheServerUrl: serverUrl, queryInfo }) - ); + return queries.map( + (queryInfo): PersistentQueryNode => ({ + dheServerUrl: serverUrl, + queryInfo, + }) + ); }; } diff --git a/src/providers/ServerConnectionPanelTreeProvider.spec.ts b/src/providers/ServerConnectionPanelTreeProvider.spec.ts new file mode 100644 index 000000000..66c435cb7 --- /dev/null +++ b/src/providers/ServerConnectionPanelTreeProvider.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import { ServerConnectionPanelTreeProvider } from './ServerConnectionPanelTreeProvider'; +import type { + ConnectionState, + IPanelService, + IPersistentQueryService, + IServerManager, + PersistentQueryNode, + ServerConnectionPanelNode, + ServerState, + VariableDefintion, + WorkerInfo, +} from '../types'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); +const IC_WORKER_URL = new URL('https://dhe.example.com:8123/worker/ic/'); +const PQ_WORKER_URL = new URL('https://dhe.example.com:8123/worker/pq/'); + +/** A running PQ with openable objects. */ +function makeQueryInfo(overrides: Record = {}): QueryInfo { + return { + serial: 'serial-1', + name: 'My PQ', + type: 'DeephavenCommunity', + designated: { + status: 'Running', + jsApiUrl: `${PQ_WORKER_URL.href}jsapi/dh-core.js`, + ideUrl: `${PQ_WORKER_URL.href}ide`, + objects: [ + { title: 'my_table', name: 'my_table', type: 'Table', id: 'v1' }, + ], + }, + ...overrides, + } as unknown as QueryInfo; +} + +function makeServerState(overrides: Partial = {}): ServerState { + return { + type: 'DHE', + url: DHE_URL, + label: 'DHE', + isConnected: true, + isRunning: true, + connectionCount: 1, + ...overrides, + } as ServerState; +} + +describe('ServerConnectionPanelTreeProvider', () => { + let serverManager: IServerManager; + let panelService: IPanelService; + let persistentQueryService: IPersistentQueryService; + let provider: ServerConnectionPanelTreeProvider; + let connections: ConnectionState[]; + + beforeEach(() => { + vi.clearAllMocks(); + + connections = [ + { + label: 'IC worker', + isConnected: true, + serverUrl: IC_WORKER_URL, + } as ConnectionState, + ]; + + panelService = { + onDidUpdate: vi.fn(() => vi.fn()), + getVariables: vi.fn(() => []), + } as unknown as IPanelService; + + persistentQueryService = { + onDidUpdate: vi.fn(() => vi.fn()), + getPersistentQueries: vi.fn(async () => [makeQueryInfo()]), + } as unknown as IPersistentQueryService; + + serverManager = { + onDidUpdate: vi.fn(() => vi.fn()), + onDidDisconnect: vi.fn(() => vi.fn()), + getConnections: vi.fn(() => connections), + getConnection: vi.fn(() => connections[0]), + registerBrowseConnection: vi.fn( + async (): Promise => + ({ workerUrl: PQ_WORKER_URL, name: 'My PQ' }) as WorkerInfo + ), + } as unknown as IServerManager; + + provider = new ServerConnectionPanelTreeProvider( + serverManager, + panelService, + persistentQueryService + ); + }); + + describe('getChildren (server)', () => { + it('lists console worker connections followed by openable PQs', async () => { + const children = (await provider.getChildren( + makeServerState() + )) as ServerConnectionPanelNode[]; + + expect(children).toHaveLength(2); + expect((children[0] as ConnectionState).label).toBe('IC worker'); + expect((children[1] as PersistentQueryNode).queryInfo.name).toBe('My PQ'); + expect((children[1] as PersistentQueryNode).dheServerUrl).toBe(DHE_URL); + }); + + it('omits PQs that have no openable objects', async () => { + ( + persistentQueryService.getPersistentQueries as ReturnType + ).mockResolvedValue([ + // Stopped: no designated worker at all. + makeQueryInfo({ name: 'Stopped PQ', designated: undefined }), + // Running but exports nothing. + makeQueryInfo({ + name: 'Empty PQ', + designated: { + status: 'Running', + jsApiUrl: `${PQ_WORKER_URL.href}jsapi/dh-core.js`, + ideUrl: `${PQ_WORKER_URL.href}ide`, + objects: [], + }, + }), + // Running with objects but no IDE endpoint (e.g. a helper query). + makeQueryInfo({ + name: 'Not browsable PQ', + designated: { + status: 'Running', + jsApiUrl: `${PQ_WORKER_URL.href}jsapi/dh-core.js`, + ideUrl: null, + objects: [{ title: 't', name: 't', type: 'Table', id: 'v1' }], + }, + }), + ]); + + const children = await provider.getChildren(makeServerState()); + expect(children).toHaveLength(1); + expect((children[0] as ConnectionState).label).toBe('IC worker'); + }); + + it('does not query PQs for a DHC server', async () => { + const children = await provider.getChildren( + makeServerState({ type: 'DHC', url: new URL('http://localhost:10000') }) + ); + + expect(children).toHaveLength(1); + expect( + persistentQueryService.getPersistentQueries + ).not.toHaveBeenCalled(); + }); + }); + + describe('getChildren (connection -> panel variables)', () => { + it('omits variables whose type is not an openable panel', async () => { + (panelService.getVariables as ReturnType).mockReturnValue([ + { id: 'v1', title: 'my_table', name: 'my_table', type: 'Table' }, + { id: 'v2', title: 'ui', name: 'ui', type: 'deephaven.ui.Element' }, + { + id: 'v3', + title: 'dash', + name: 'dash', + type: 'deephaven.ui.Dashboard', + }, + { id: 'v4', title: 'legacy', name: 'legacy', type: 'TableMap' }, + { id: 'v5', title: 'tree', name: 'tree', type: 'Treemap' }, + { id: 'v6', title: 'widget', name: 'widget', type: 'OtherWidget' }, + { id: 'v7', title: 'acl', name: 'acl', type: 'AclService' }, + ]); + + const leaves = (await provider.getChildren(connections[0])) as [ + URL, + VariableDefintion, + ][]; + + expect(leaves.map(([, v]) => v.title)).toEqual(['my_table', 'ui']); + }); + }); + + describe('getChildren (PQ -> object leaves)', () => { + it('registers a browse connection and returns the PQ object leaves', async () => { + const node: PersistentQueryNode = { + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo(), + }; + + const leaves = (await provider.getChildren(node)) as [ + URL, + VariableDefintion, + ][]; + + expect(serverManager.registerBrowseConnection).toHaveBeenCalledWith( + DHE_URL, + node.queryInfo + ); + expect(leaves.map(([, v]) => v.title)).toEqual(['my_table']); + leaves.forEach(([url]) => expect(url.href).toBe(PQ_WORKER_URL.href)); + }); + }); + + describe('getTreeItem', () => { + it('renders a PQ node with the language icon, matching console workers', async () => { + const item = await provider.getTreeItem({ + dheServerUrl: DHE_URL, + queryInfo: makeQueryInfo({ scriptLanguage: 'Python' }), + }); + + expect(item.label).toBe('My PQ'); + expect(item.contextValue).toBe('isPersistentQuery'); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('dh-python'); + }); + + it('renders a server node', async () => { + const item = await provider.getTreeItem(makeServerState()); + expect(item.label).toBe('DHE'); + }); + + it('offers the delete action on a console session variable', async () => { + (serverManager.getConnection as ReturnType).mockReturnValue( + { + label: 'IC worker', + isConnected: true, + serverUrl: IC_WORKER_URL, + } + ); + + const item = await provider.getTreeItem([ + IC_WORKER_URL, + { title: 'my_table', name: 'my_table', type: 'Table' }, + ] as [URL, VariableDefintion]); + + expect(item.contextValue).toBe('canDeleteDeephavenVariable'); + }); + + it('offers no delete action on a PQ object (browse connection)', async () => { + (serverManager.getConnection as ReturnType).mockReturnValue( + { + label: 'My PQ', + isConnected: true, + isBrowseConnection: true, + serverUrl: PQ_WORKER_URL, + } + ); + + const item = await provider.getTreeItem([ + PQ_WORKER_URL, + { title: 'my_table', name: 'my_table', type: 'Table' }, + ] as [URL, VariableDefintion]); + + expect(item.contextValue).toBeUndefined(); + }); + }); +}); diff --git a/src/providers/ServerConnectionPanelTreeProvider.ts b/src/providers/ServerConnectionPanelTreeProvider.ts index 98369df70..34bf2dbbd 100644 --- a/src/providers/ServerConnectionPanelTreeProvider.ts +++ b/src/providers/ServerConnectionPanelTreeProvider.ts @@ -1,41 +1,84 @@ import * as vscode from 'vscode'; import type { IPanelService, + IPersistentQueryService, IServerManager, + PersistentQueryNode, ServerConnectionPanelNode, } from '../types'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { + canBrowsePersistentQueryObjects, getConnectionServerTreeItem, getConnectionTreeRootNodes, getPanelConnectionTreeItem, getPanelVariableTreeItem, + getPersistentQueryObjectLeaves, + getPersistentQueryObjects, + getPersistentQueryTreeItem, + isOpenablePanelVariable, + isPersistentQueryNode, isServerStateNode, sortByStringProp, } from '../util'; import { getFirstSupportedConsoleType } from '../services'; +/** + * Tree data provider backing the **Panels** view. Each server node lists its + * console worker connections (panels come from the live session) plus the + * persistent queries whose exported objects can be opened — a PQ is listed here + * only when it has objects and a browsable worker, since opening panels is the + * whole point of this view. Dead-end PQs (stopped, no objects, no IDE endpoint) + * are left to the Persistent Queries view. + */ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase { - constructor(serverManager: IServerManager, panelService: IPanelService) { + constructor( + serverManager: IServerManager, + panelService: IPanelService, + persistentQueryService: IPersistentQueryService + ) { super(serverManager); this._panelService = panelService; + this._persistentQueryService = persistentQueryService; this._panelService.onDidUpdate(() => { this._onDidChangeTreeData.fire(); }); + + // Refresh whenever the PQ set ticks. + this.disposables.add( + this._persistentQueryService.onDidUpdate(() => { + this._onDidChangeTreeData.fire(); + }) + ); } private readonly _panelService: IPanelService; + private readonly _persistentQueryService: IPersistentQueryService; getTreeItem = async ( node: ServerConnectionPanelNode ): Promise => { - // Variable leaf node. + // Variable leaf node. A leaf hanging off a PQ is hosted by a browse + // connection and is browse-only, so it gets no delete action; variables in a + // console session do. if (Array.isArray(node)) { - return getPanelVariableTreeItem(node); + const [workerUrl] = node; + const isBrowseOnly = + this.serverManager.getConnection(workerUrl)?.isBrowseConnection === + true; + + return getPanelVariableTreeItem(node, !isBrowseOnly); } - // DHE server node grouping its worker connections. + // Persistent-query node grouping its exported objects. Language icon, not a + // status circle: every PQ listed here is running, and this view's other + // children are console workers drawn the same way. + if (isPersistentQueryNode(node)) { + return getPersistentQueryTreeItem(node, 'language'); + } + + // DHE server node grouping its worker connections + persistent queries. if (isServerStateNode(node)) { return getConnectionServerTreeItem(node); } @@ -47,9 +90,9 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase => { + ): Promise => { // Root: one server node per server that has connections. if (elementOrRoot == null) { return getConnectionTreeRootNodes(this.serverManager); @@ -60,15 +103,59 @@ export class ServerConnectionPanelTreeProvider extends ServerTreeProviderBase its worker connections. + // PQ node -> its exported object leaves. The worker URL is registered as a + // browse connection so the embed panel can authenticate. + if (isPersistentQueryNode(elementOrRoot)) { + const { dheServerUrl, queryInfo } = elementOrRoot; + + const workerInfo = await this.serverManager.registerBrowseConnection( + dheServerUrl, + queryInfo + ); + + if (workerInfo == null) { + return []; + } + + return getPersistentQueryObjectLeaves( + new URL(workerInfo.workerUrl), + queryInfo + ); + } + + // Server node -> its worker connections, then its openable PQs. if (isServerStateNode(elementOrRoot)) { - return this.serverManager + const connections = this.serverManager .getConnections(elementOrRoot.url) .sort(sortByStringProp('serverUrl')); + + if (elementOrRoot.type !== 'DHE') { + return connections; + } + + const serverUrl = elementOrRoot.url; + const queries = + await this._persistentQueryService.getPersistentQueries(serverUrl); + + const persistentQueryNodes = queries + .filter( + queryInfo => + getPersistentQueryObjects(queryInfo).length > 0 && + canBrowsePersistentQueryObjects(queryInfo) + ) + .map( + (queryInfo): PersistentQueryNode => ({ + dheServerUrl: serverUrl, + queryInfo, + }) + ); + + return [...connections, ...persistentQueryNodes]; } - // Connection node -> its panel variables. + // Connection node -> its panel variables (only ones that can open). return [...this._panelService.getVariables(elementOrRoot.serverUrl)] + .filter(isOpenablePanelVariable) .sort(sortByStringProp('title')) .map(variable => [elementOrRoot.serverUrl, variable]); }; diff --git a/src/providers/ServerConnectionTreeProvider.ts b/src/providers/ServerConnectionTreeProvider.ts index d6fd7654a..60caff54f 100644 --- a/src/providers/ServerConnectionTreeProvider.ts +++ b/src/providers/ServerConnectionTreeProvider.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { ServerTreeProviderBase } from './ServerTreeProviderBase'; import { CONNECTION_TREE_ITEM_CONTEXT, ICON_ID } from '../common'; -import type { ConsoleType, ServerConnectionNode } from '../types'; +import type { ServerConnectionNode } from '../types'; import { getConnectionServerTreeItem, getConnectionTreeRootNodes, @@ -10,7 +10,7 @@ import { isServerStateNode, sortByStringProp, } from '../util'; -import { DhcService } from '../services'; +import { DhcService, getFirstSupportedConsoleType } from '../services'; /** * Provider for the server connection tree view. @@ -39,10 +39,7 @@ export class ServerConnectionTreeProvider extends ServerTreeProviderBase !v.title.startsWith('_')); + // Everything created is logged above; only open panels for variables that + // can actually render in one (a `deephaven.ui.Dashboard`, for example, would + // open a blank panel). + const showVariables = changed.filter( + v => !v.title.startsWith('_') && isOpenablePanelVariable(v) + ); if (isNonEmptyArray(showVariables)) { logger.debug( diff --git a/src/services/DheService.ts b/src/services/DheService.ts index f6dcdbc81..ae547200a 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -124,7 +124,8 @@ export class DheService implements IDheService { private _clientPromise: Promise | null = null; - private _corePlusManagerPromise: Promise | null = null; + private _corePlusManagerPromise: Promise | null = + null; private _isConnected: boolean = false; private _operateAs: string | null = null; private _removeConfigListeners: (() => void) | null = null; diff --git a/src/services/PersistentQueryService.spec.ts b/src/services/PersistentQueryService.spec.ts new file mode 100644 index 000000000..8eef21480 --- /dev/null +++ b/src/services/PersistentQueryService.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import { PersistentQueryService } from './PersistentQueryService'; +import { getQueryInfoTableMock } from '../testUtils'; +import type { IAsyncCacheService, IDheService, IServerManager } from '../types'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +// Control the QueryConfigTableService the service constructs internally. +const getQueryInfoTable = vi.fn(); +vi.mock('./QueryConfigTableService', async importActual => { + const actual = + await importActual(); + return { + ...actual, + // eslint-disable-next-line @typescript-eslint/naming-convention + QueryConfigTableService: class { + getQueryInfoTable = getQueryInfoTable; + dispose = vi.fn(async () => {}); + }, + }; +}); + +const DHE_URL = new URL('https://dhe.example.com:8123/'); + +function makeQueryInfo(overrides: Partial = {}): QueryInfo { + return { + serial: 'serial-1', + name: 'My PQ', + type: 'DeephavenCommunity', + designated: { status: 'Running', objects: [] }, + ...overrides, + } as unknown as QueryInfo; +} + +/** A `QueryInfoTableSubscription` mock over the given serials. */ +const makeSubscription = (serials: string[]): unknown => + getQueryInfoTableMock({ serials }); + +describe('PersistentQueryService', () => { + let serverManager: IServerManager; + let dheServiceCache: IAsyncCacheService; + let dheJsApiCache: IAsyncCacheService; + let service: PersistentQueryService; + let knownConfigs: QueryInfo[]; + + beforeEach(() => { + vi.clearAllMocks(); + + knownConfigs = [makeQueryInfo()]; + + const dheService = { + getClient: vi.fn(async () => ({ + client: { getKnownConfigs: vi.fn(() => knownConfigs) }, + })), + } as unknown as IDheService; + + dheServiceCache = { + get: vi.fn(async () => dheService), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + dheJsApiCache = { + get: vi.fn(async () => ({}) as never), + has: vi.fn(() => true), + } as unknown as IAsyncCacheService; + + serverManager = { + onDidDisconnect: vi.fn(() => vi.fn()), + } as unknown as IServerManager; + + service = new PersistentQueryService( + serverManager, + dheServiceCache, + dheJsApiCache + ); + }); + + it('applies an exclude-helper-types filter with no status filter', async () => { + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-1'])); + + await service.getPersistentQueries(DHE_URL); + + expect(getQueryInfoTable).toHaveBeenCalledWith({ + excludeHelperTypes: true, + }); + }); + + it('resolves the ticking serials to known configs, sorted by name', async () => { + knownConfigs = [ + makeQueryInfo({ serial: 'serial-1', name: 'Zeta PQ' }), + makeQueryInfo({ serial: 'serial-2', name: 'Alpha PQ' }), + // InteractiveConsole — belongs to the Workers tree, never listed here. + makeQueryInfo({ serial: 'serial-ic', type: 'InteractiveConsole' }), + // Not in the filtered table — excluded. + makeQueryInfo({ serial: 'serial-hidden', name: 'Hidden' }), + ]; + + getQueryInfoTable.mockResolvedValue( + makeSubscription(['serial-1', 'serial-2', 'serial-ic']) + ); + + const queries = await service.getPersistentQueries(DHE_URL); + expect(queries.map(q => q.name)).toEqual(['Alpha PQ', 'Zeta PQ']); + }); + + it('includes stopped queries (no status filter, no designated worker)', async () => { + knownConfigs = [ + makeQueryInfo({ + serial: 'serial-stopped', + name: 'Stopped PQ', + designated: undefined, + } as Partial), + ]; + + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-stopped'])); + + const queries = await service.getPersistentQueries(DHE_URL); + expect(queries.map(q => q.name)).toEqual(['Stopped PQ']); + }); + + it('returns an empty list when the table fails to load', async () => { + getQueryInfoTable.mockRejectedValue(new Error('WebClientData down')); + + expect(await service.getPersistentQueries(DHE_URL)).toEqual([]); + }); + + it('reuses one subscription per server across calls', async () => { + getQueryInfoTable.mockResolvedValue(makeSubscription(['serial-1'])); + + await service.getPersistentQueries(DHE_URL); + await service.getPersistentQueries(DHE_URL); + + expect(getQueryInfoTable).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/services/PersistentQueryService.ts b/src/services/PersistentQueryService.ts new file mode 100644 index 000000000..78056a7b5 --- /dev/null +++ b/src/services/PersistentQueryService.ts @@ -0,0 +1,217 @@ +import * as vscode from 'vscode'; +import type { QueryInfo } from '@deephaven-enterprise/jsapi-types'; +import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import { INTERACTIVE_CONSOLE_QUERY_TYPE } from '../common'; +import type { + IAsyncCacheService, + IDheService, + IPersistentQueryService, + IServerManager, +} from '../types'; +import { Logger } from '../util'; +import { DisposableBase } from './DisposableBase'; +import { + QueryConfigTableService, + type QueryInfoTableSubscription, +} from './QueryConfigTableService'; + +const logger = new Logger('PersistentQueryService'); + +/** + * Per-DHE-server source of the ACL-visible persistent queries, shared by every + * view that lists PQs (the Persistent Queries tree and the Panels tree). + * + * Serials come from the ticking `QueryInfo` table (server-side filtered to + * non-InteractiveConsole / non-helper types, any status) and are resolved to + * full `QueryInfo` objects via `getKnownConfigs()` — the table rows carry no + * `designated` block, which is where a PQ's status and exported objects live. + * `onDidUpdate` fires on every table tick so consumers can refresh. + */ +export class PersistentQueryService + extends DisposableBase + implements IPersistentQueryService +{ + /** + * @param serverManager Server manager (for disconnect teardown). + * @param dheServiceCache Cache providing the DHE service per server URL. + * @param dheJsApiCache Cache providing the DHE JS API per server URL. + */ + constructor( + serverManager: IServerManager, + dheServiceCache: IAsyncCacheService, + dheJsApiCache: IAsyncCacheService + ) { + super(); + this._dheServiceCache = dheServiceCache; + this._dheJsApiCache = dheJsApiCache; + + // Tear down per-server table subscriptions for any server that goes away + // (disconnect / config change removing the server). + this.disposables.add( + serverManager.onDidDisconnect(url => { + this._disposeServer(url); + }) + ); + } + + private readonly _dheServiceCache: IAsyncCacheService; + private readonly _dheJsApiCache: IAsyncCacheService; + + private readonly _onDidUpdate = new vscode.EventEmitter(); + readonly onDidUpdate = this._onDidUpdate.event; + + /** One `QueryConfigTableService` per DHE server URL. */ + private readonly _tableServiceMap = new Map< + string, + QueryConfigTableService + >(); + /** The ticking `QueryInfo` table subscription per DHE server URL. */ + private readonly _subscriptionMap = new Map< + string, + Promise + >(); + + /** + * Get (creating if needed) the ticking `QueryInfo` table subscription for a + * DHE server. Cached per server; every tick re-fires `onDidUpdate`. + * @param serverUrl The DHE server URL. + */ + private _getSubscription = ( + serverUrl: URL + ): Promise => { + const key = serverUrl.toString(); + + let subscriptionPromise = this._subscriptionMap.get(key); + if (subscriptionPromise != null) { + return subscriptionPromise; + } + + subscriptionPromise = (async (): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + + let tableService = this._tableServiceMap.get(key); + if (tableService == null) { + tableService = new QueryConfigTableService( + serverUrl, + dheService, + this._dheJsApiCache + ); + this._tableServiceMap.set(key, tableService); + this.disposables.add(tableService); + } + + // Server-side filter: exclude InteractiveConsole + other helper/system + // query types. Deliberately unfiltered by status so stopped / failed PQs + // still list (their state is rendered on the node). + const subscription = await tableService.getQueryInfoTable({ + excludeHelperTypes: true, + }); + + this.disposables.add( + subscription.onDidUpdate(() => { + this._onDidUpdate.fire(); + }) + ); + + return subscription; + })(); + + this._subscriptionMap.set(key, subscriptionPromise); + + // If the table fails to load (e.g. WebClientData unavailable), drop the + // cached rejection so a later refresh can retry. + subscriptionPromise.catch(err => { + logger.error(`Failed to load QueryInfo table for ${serverUrl}:`, err); + if (this._subscriptionMap.get(key) === subscriptionPromise) { + this._subscriptionMap.delete(key); + } + }); + + return subscriptionPromise; + }; + + /** + * Dispose the table subscription + service for a server (on disconnect). + * @param serverUrl The DHE server URL. + */ + private _disposeServer = (serverUrl: URL): void => { + const key = serverUrl.toString(); + + const subscriptionPromise = this._subscriptionMap.get(key); + this._subscriptionMap.delete(key); + if (subscriptionPromise != null) { + void subscriptionPromise + .then(subscription => subscription.dispose()) + .catch(() => { + // Ignore — already logged / disposed. + }); + } + + const tableService = this._tableServiceMap.get(key); + this._tableServiceMap.delete(key); + if (tableService != null) { + this.disposables.delete(tableService); + void tableService.dispose(); + } + + this._onDidUpdate.fire(); + }; + + /** + * The persistent queries visible on a DHE server, sorted by name. Any status + * (running, stopped, failed) — InteractiveConsole workers are never included; + * those belong to the Workers tree. + * @param serverUrl The DHE server URL. + */ + getPersistentQueries = async (serverUrl: URL): Promise => { + const dheService = await this._dheServiceCache.get(serverUrl); + const dheClient = await dheService.getClient(false); + if (dheClient == null) { + return []; + } + + let subscription: QueryInfoTableSubscription; + try { + subscription = await this._getSubscription(serverUrl); + } catch { + // Error already logged in `_getSubscription`; show an empty PQ list. + return []; + } + + const serials = subscription.getQuerySerials(); + + const knownConfigs = dheClient.client.getKnownConfigs(); + const knownSerials = new Set( + knownConfigs.map(queryInfo => String(queryInfo.serial)) + ); + + const queries = knownConfigs.filter( + queryInfo => + queryInfo.serial != null && + serials.has(String(queryInfo.serial)) && + // Defense in depth: never list InteractiveConsole workers here (they + // live in the WORKERS tree). + queryInfo.type !== INTERACTIVE_CONSOLE_QUERY_TYPE + ); + + // A serial in the table with no matching config would silently vanish from + // the tree, so surface it rather than dropping it quietly. + const unresolved = [...serials].filter(serial => !knownSerials.has(serial)); + if (unresolved.length > 0) { + logger.debug( + `Table serials with no known config for ${serverUrl.href}:`, + unresolved + ); + } + + logger.debug( + `PQs for ${serverUrl.href}: table serials=${serials.size}, known configs=${knownConfigs.length}, resolved=${queries.length}` + ); + + return queries.sort((a, b) => a.name.localeCompare(b.name)); + }; + + protected override async onDisposing(): Promise { + this._onDidUpdate.dispose(); + } +} diff --git a/src/services/QueryConfigTableService.spec.ts b/src/services/QueryConfigTableService.spec.ts index 6db0b3fb7..8211c6594 100644 --- a/src/services/QueryConfigTableService.spec.ts +++ b/src/services/QueryConfigTableService.spec.ts @@ -1,8 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { dh as DhcType } from '@deephaven/jsapi-types'; -import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; import { getQueryTableFilters } from './QueryConfigTableService'; +/** The API-object parameter type of `getQueryTableFilters`. */ +type QueryFilterApi = Parameters[0]; + // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); @@ -64,7 +66,7 @@ const dh = { FilterValue: { ofString: (value: string): { value: string } => ({ value }), }, -} as unknown as DheType; +} as unknown as QueryFilterApi; describe('getQueryTableFilters', () => { let mock: ReturnType; @@ -176,11 +178,6 @@ describe('getQueryTableFilters', () => { }); expect( conditions.map(c => (c as unknown as { column: string }).column) - ).toEqual([ - 'Owner', - 'QueryType', - 'Status', - 'Name', - ]); + ).toEqual(['Owner', 'QueryType', 'Status', 'Name']); }); }); diff --git a/src/services/QueryConfigTableService.ts b/src/services/QueryConfigTableService.ts index 257b6e338..9ed29ae2f 100644 --- a/src/services/QueryConfigTableService.ts +++ b/src/services/QueryConfigTableService.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; import type { EnterpriseDhType as DheType } from '@deephaven-enterprise/jsapi-types'; +import type { CorePlusManager } from '@deephaven-enterprise/client-utils'; import { EXCLUDED_QUERY_TYPES, makeFactoryServiceTablePromise, @@ -8,16 +9,21 @@ import { QUERY_CONFIG_TABLE, WEB_CLIENT_DATA_CORE_QUERY, } from '@deephaven-enterprise/query-utils'; -import type { - IAsyncCacheService, - IDheService, - IDisposable, -} from '../types'; +import type { IAsyncCacheService, IDheService, IDisposable } from '../types'; import { Logger } from '../util'; import { DisposableBase } from './DisposableBase'; const logger = new Logger('QueryConfigTableService'); +/** + * The community (Core) JS API object returned by `CorePlusManager.getApi`. The + * `QueryInfo` table is created by the WebClientData worker's community API, so + * server-side filter values must be built from *this* API — a `FilterValue` + * from any other API instance (e.g. the enterprise `dhe`) throws a + * `java.lang.ClassCastException` when the table tries to cast it. + */ +type CoreApi = Awaited>; + /** * Error thrown when the `WebClientData` Core+ system query required to fetch the * `QueryInfo` table is unavailable (not visible to the current user or not @@ -58,13 +64,14 @@ export interface QueryTableFilters { * Build the server-side `FilterCondition[]` for the `QueryInfo` table from the * given filters. Pure — no I/O or subscription side effects, so it can be unit * tested against a mocked table. Mirrors `PQExplorerPanel.getQueryTableFilters`. - * @param dh The (enterprise) DH API providing `FilterValue`. + * @param dh The community DH API that created `table`, providing `FilterValue`. + * Must be the table's own API (see {@link CoreApi}). * @param table The `QueryInfo` table to build columns/filters from. * @param filters The filters to apply. * @returns An array of `FilterCondition` to pass to `table.applyFilter`. */ export function getQueryTableFilters( - dh: DheType, + dh: CoreApi, table: DhcType.Table, filters: QueryTableFilters ): DhcType.FilterCondition[] { @@ -82,9 +89,7 @@ export function getQueryTableFilters( return filterValue.in(values.map(value => dh.FilterValue.ofString(value))); }; - const pushIfNonEmpty = ( - condition: DhcType.FilterCondition | null - ): void => { + const pushIfNonEmpty = (condition: DhcType.FilterCondition | null): void => { if (condition != null) { conditions.push(condition); } @@ -126,14 +131,22 @@ export function getQueryTableFilters( } /** - * A subscription over a filtered `QueryInfo` table. Emits the current set of - * filtered rows on every server tick (`EVENT_UPDATED`) until disposed. + * A subscription over a filtered `QueryInfo` table. Ticks on every server + * update (`EVENT_UPDATED`) until disposed, keeping {@link getQuerySerials} in + * sync with the filtered row set. */ export interface QueryInfoTableSubscription extends IDisposable { /** The underlying (filtered) `QueryInfo` table. */ readonly table: DhcType.Table; - /** Fires with a snapshot of the current viewport rows on each update. */ - readonly onDidUpdate: vscode.Event; + /** Fires on every tick of the filtered row set. */ + readonly onDidUpdate: vscode.Event; + /** + * Serials of the current filtered rows, excluding child-replica rows (rows + * with `Parent` set). Reflects the most recent tick — empty until the first + * one arrives, so consumers must refresh on {@link onDidUpdate} rather than + * treating an empty set as "no queries". + */ + getQuerySerials: () => ReadonlySet; } /** @@ -165,11 +178,16 @@ export class QueryConfigTableService extends DisposableBase { /** * Fetch the (unfiltered) `QueryInfo` table via the WebClientData factory - * service. Throws a clear error when `WebClientData` is unavailable - * (Gotcha 7) rather than hanging on the upstream widget-message path. - * @returns The `QueryInfo` table. + * service, along with the community API that created it. Throws a clear error + * when `WebClientData` is unavailable (Gotcha 7) rather than hanging on the + * upstream widget-message path. + * @returns The `QueryInfo` table and the community API that created it. Filter + * values must be built from that API (see {@link CoreApi}). */ - private async _fetchQueryInfoTable(): Promise { + private async _fetchQueryInfoTable(): Promise<{ + table: DhcType.Table; + coreApi: CoreApi; + }> { const dheClient = await this._dheService.getClient(false); if (dheClient == null) { throw new Error( @@ -188,28 +206,38 @@ export class QueryConfigTableService extends DisposableBase { // running. `makeFactoryServiceTablePromise` routes through a widget-message // helper whose rejection is not reliably propagated (upstream DH-20345), so // guard here to surface a clear error instead of hanging (Gotcha 7). - const hasWebClientData = dheClient.client + const webClientData = dheClient.client .getKnownConfigs() - .some( + .find( qi => qi.name === WEB_CLIENT_DATA_CORE_QUERY && qi.designated?.status === 'Running' ); - if (!hasWebClientData) { + if (webClientData?.designated == null) { throw new WebClientDataUnavailableError(this._serverUrl); } const dhe = await this._dheJsApiCache.get(this._serverUrl); const userInfo = await dheClient.client.getUserInfo(); - return makeFactoryServiceTablePromise({ + const table = await makeFactoryServiceTablePromise({ client: dheClient.client, corePlusManager, dh: dhe, userInfo, tableName: QUERY_CONFIG_TABLE, }); + + // The table is served by the WebClientData worker's *community* API. Grab + // that same (cached) API instance so filter values are castable by the + // table — `getApi` returns the instance the factory used above. + const coreApi = await corePlusManager.getApi( + webClientData.workerKind, + webClientData.designated.jsApiUrl + ); + + return { table, coreApi }; } /** @@ -222,37 +250,66 @@ export class QueryConfigTableService extends DisposableBase { getQueryInfoTable = async ( filters: QueryTableFilters = {} ): Promise => { - const dhe = await this._dheJsApiCache.get(this._serverUrl); - const table = await this._fetchQueryInfoTable(); + const { table, coreApi } = await this._fetchQueryInfoTable(); - const conditions = getQueryTableFilters(dhe, table, filters); + const conditions = getQueryTableFilters(coreApi, table, filters); table.applyFilter(conditions); - const onDidUpdateEmitter = - new vscode.EventEmitter(); + const onDidUpdateEmitter = new vscode.EventEmitter(); - const onUpdate = ({ - detail, - }: DhcType.Event): void => { - onDidUpdateEmitter.fire(detail); - }; + const serialColumn = table.findColumn(QueryColumns.SERIAL.name); + const parentColumn = table.findColumn(QueryColumns.PARENT_ID.name); + const statusColumn = table.findColumn(QueryColumns.STATUS.name); - const removeUpdateListener = table.addEventListener( - dhe.Table.EVENT_UPDATED, - onUpdate - ); + let querySerials: ReadonlySet = new Set(); + + // Subscribe to the whole filtered table rather than tracking a viewport. + // A viewport has to be re-pinned as the filtered size changes, and + // `getViewportData()` only returns the snapshot that has arrived so far, so + // rows that tick in later were silently dropped (PQs missing from the + // tree). A full subscription's `EVENT_UPDATED` carries every row the client + // has, so each tick is a complete picture of the filtered set. + // + // Only the columns consumers act on are subscribed, so the table ticks on + // row add/remove and status transitions but not on churn like heap usage. + // `Status` earns its place even though the value is unused here: a PQ going + // Running → Stopped must re-render, and the resolved `QueryInfo` carries the + // new status. + const tableSubscription = table.subscribe([ + serialColumn, + parentColumn, + statusColumn, + ]); - // Subscribe to all rows so the table ticks. The viewport is refreshed as - // the filtered size changes. - table.setViewport(0, Math.max(0, table.size - 1)); + const removeUpdateListener = + tableSubscription.addEventListener( + coreApi.Table.EVENT_UPDATED, + ({ detail }) => { + const serials = new Set(); + + for (const row of detail.rows) { + // Skip child-replica rows; the parent row represents the query. + const parent = row.get(parentColumn); + if (parent != null && String(parent).length > 0) { + continue; + } + serials.add(String(row.get(serialColumn))); + } + + querySerials = serials; + onDidUpdateEmitter.fire(); + } + ); const subscription: QueryInfoTableSubscription = { table, onDidUpdate: onDidUpdateEmitter.event, + getQuerySerials: () => querySerials, dispose: async (): Promise => { removeUpdateListener(); onDidUpdateEmitter.dispose(); try { + tableSubscription.close(); table.close(); } catch (err) { logger.debug('Error closing QueryInfo table', err); diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index f9161f6db..690eee688 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -694,6 +694,9 @@ export class ServerManager implements IServerManager { // never counted as a worker connection. if (!this._connectionMap.has(workerUrl)) { this._connectionMap.set(workerUrl, { + // Flagged so `getConnections` can exclude it: this is an auth/panel + // shim, not a worker connection the user can select or run code on. + isBrowseConnection: true, label: workerInfo.name, isConnected: true, isRunningCode: false, @@ -930,17 +933,25 @@ export class ServerManager implements IServerManager { }; /** - * Get all connections. Optionally filter connections by server or worker URL. + * Get all worker connections. Optionally filter connections by server or + * worker URL. PQ browse connections are always excluded — they are panel-auth + * shims, not worker connections, so they must not show up in the Workers tree, + * the connection picker, or any "run code here" target list. Use + * `getConnection` to look one up directly by worker URL. * @param serverOrWorkerUrl The server or worker URL to filter connections by. - * @returns An array of all connections. + * @returns An array of all worker connections. */ getConnections = (serverOrWorkerUrl?: URL): ConnectionState[] => { + const isWorkerConnection = (connection: ConnectionState): boolean => + connection.isBrowseConnection !== true; + if (serverOrWorkerUrl == null) { - return [...this._connectionMap.values()]; + return [...this._connectionMap.values()].filter(isWorkerConnection); } if (this._connectionMap.has(serverOrWorkerUrl)) { - return [this._connectionMap.getOrThrow(serverOrWorkerUrl)]; + const connection = this._connectionMap.getOrThrow(serverOrWorkerUrl); + return isWorkerConnection(connection) ? [connection] : []; } const server = this.getServer(serverOrWorkerUrl); @@ -950,11 +961,16 @@ export class ServerManager implements IServerManager { if (server.type === 'DHC') { const connection = this._connectionMap.get(serverOrWorkerUrl); - return connection == null ? [] : [connection]; + return connection == null || !isWorkerConnection(connection) + ? [] + : [connection]; } // For DHE, return all connections associated with the server URL return [...this._connectionMap.values()].filter(connection => { + if (!isWorkerConnection(connection)) { + return false; + } const dheServerUrl = this._workerURLToServerURLMap.get(connection.serverUrl) ?? connection.serverUrl; diff --git a/src/services/index.ts b/src/services/index.ts index 671a5a960..f6831ee4d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -7,6 +7,7 @@ export * from './DisposableBase'; export * from './FilteredWorkspace'; export * from './PanelService'; export * from './ParseDocumentCache'; +export * from './PersistentQueryService'; export * from './PollingService'; export * from './QueryConfigTableService'; export * from './RemoteFileSourceService'; diff --git a/src/testUtils.ts b/src/testUtils.ts index 2ec96fd80..65dfd2208 100644 --- a/src/testUtils.ts +++ b/src/testUtils.ts @@ -41,32 +41,21 @@ export function getLastEventListener< } /** - * Build a mock `QueryInfoTableSubscription` whose `table.getViewportData()` - * returns rows backed by the given plain objects, keyed by column name. Each - * `row.get(column)` reads `row[column.name]`. Used to test consumers of the - * ticking `QueryInfo` table without a live server. - * @param options.rows Row records keyed by column name. - * @returns A mocked subscription (only `table` + `onDidUpdate` are populated). + * Build a mock `QueryInfoTableSubscription` reporting the given query serials. + * Used to test consumers of the ticking `QueryInfo` table without a live server. + * @param options.serials The serials the latest tick reported (child-replica + * rows are already excluded by the real subscription). + * @returns A mocked subscription. */ export function getQueryInfoTableMock({ - rows, + serials, }: { - rows: Record[]; + serials: string[]; }): unknown { - const findColumn = (name: string): { name: string } => ({ name }); - - const viewportRows = rows.map(record => ({ - get: (column: { name: string }): unknown => record[column.name], - })); - - const table = { - findColumn, - getViewportData: vi.fn(async () => ({ rows: viewportRows })), - }; - return { - table, + table: {}, onDidUpdate: vi.fn(() => () => {}), + getQuerySerials: () => new Set(serials), dispose: vi.fn(async () => {}), }; } diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index a5b0dd32d..130bd269a 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -168,6 +168,15 @@ export interface WorkerConfig { } export interface ConnectionState { + /** + * True for a lightweight PQ browse connection: an auth/panel shim registered + * by `ServerManager.registerBrowseConnection`, with no console session behind + * it. These are excluded from `getConnections` — they are not worker + * connections and must never appear in the Workers tree or the connection + * picker. PQs surface as their own nodes, sourced from + * `IPersistentQueryService`. + */ + readonly isBrowseConnection?: boolean; readonly isConnected: boolean; readonly isRunningCode?: boolean; readonly label: string; diff --git a/src/types/serviceTypes.d.ts b/src/types/serviceTypes.d.ts index 892cc2465..4d3198156 100644 --- a/src/types/serviceTypes.d.ts +++ b/src/types/serviceTypes.d.ts @@ -153,6 +153,17 @@ export interface IPanelService extends IDisposable { updateVariables: (url: URL, changes: VariableChanges) => void; } +/** + * Source of the persistent queries visible on a DHE server, shared by every view + * that lists PQs. `onDidUpdate` fires whenever the underlying (ticking) + * `QueryInfo` table changes. + */ +export interface IPersistentQueryService extends IDisposable { + readonly onDidUpdate: vscode.Event; + + getPersistentQueries: (serverUrl: URL) => Promise; +} + /** * Secret service interface. */ diff --git a/src/types/treeViewTypes.d.ts b/src/types/treeViewTypes.d.ts index bc768b1e8..077f52406 100644 --- a/src/types/treeViewTypes.d.ts +++ b/src/types/treeViewTypes.d.ts @@ -15,9 +15,17 @@ export type ServerConnectionNode = ServerState | ConnectionState | vscode.Uri; export interface ServerConnectionTreeView extends vscode.TreeView {} +/** + * A node in the Panels tree: + * - `ServerState`: a server grouping its console workers + persistent queries. + * - `ConnectionState`: a console worker connection (panels come from its session). + * - `PersistentQueryNode`: a PQ whose exported objects can be opened. + * - `[URL, VariableDefintion]`: a panel / object leaf. + */ export type ServerConnectionPanelNode = | ServerState | ConnectionState + | PersistentQueryNode | [URL, VariableDefintion]; export interface ServerConnectionPanelTreeView diff --git a/src/util/__snapshots__/treeViewUtils.spec.ts.snap b/src/util/__snapshots__/treeViewUtils.spec.ts.snap index 41d4b4fb1..3427460a5 100644 --- a/src/util/__snapshots__/treeViewUtils.spec.ts.snap +++ b/src/util/__snapshots__/treeViewUtils.spec.ts.snap @@ -51,7 +51,7 @@ exports[`getPanelConnectionTreeItem > should return panel connection tree item: "collapsibleState": 2, "iconPath": ThemeIcon { "color": undefined, - "id": "vm-connect", + "id": "remote", }, "label": "Some Worker Label", } diff --git a/src/util/panelUtils.spec.ts b/src/util/panelUtils.spec.ts new file mode 100644 index 000000000..a44eecbfb --- /dev/null +++ b/src/util/panelUtils.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { isOpenablePanelVariable } from './panelUtils'; + +// See __mocks__/vscode.ts for the mock implementation +vi.mock('vscode'); + +describe('isOpenablePanelVariable', () => { + it.each([ + // Grid plugin. + ['Table'], + ['TreeTable'], + ['HierarchicalTable'], + ['PartitionedTable'], + // Chart / Pandas plugins. + ['Figure'], + ['pandas.DataFrame'], + // First-party plugin widgets — deephaven.ui panels are openable. + ['deephaven.ui.Element'], + ['deephaven.plot.express.DeephavenFigure'], + ])('is true for an openable type: %s', type => { + expect(isOpenablePanelVariable({ title: 't', type })).toBe(true); + }); + + it.each([ + // A dashboard, not a panel. + ['deephaven.ui.Dashboard'], + // Legacy types no plugin claims. + ['TableMap'], + ['Treemap'], + // The server's catch-all widget type. + ['OtherWidget'], + // DHE service objects exported by a worker. + ['AclService'], + // An unknown server plugin type — hidden until added to the allow-list. + ['some.server.PluginWidget'], + ])('is false for a type that is not an openable panel: %s', type => { + expect(isOpenablePanelVariable({ title: 't', type })).toBe(false); + }); + + it.each([ + [{ title: '', type: 'Table' }], + [{ title: null, type: 'Table' }], + [{ type: 'Table' }], + [{ title: 't', type: '' }], + [{ title: 't', type: null }], + [{ title: 't' }], + [{}], + ])('is false without both a title and an openable type: %s', variable => { + expect(isOpenablePanelVariable(variable)).toBe(false); + }); +}); diff --git a/src/util/panelUtils.ts b/src/util/panelUtils.ts index d3c4846d4..30751d2fd 100644 --- a/src/util/panelUtils.ts +++ b/src/util/panelUtils.ts @@ -1,6 +1,10 @@ import * as vscode from 'vscode'; import type { dh as DhcType } from '@deephaven/jsapi-types'; -import { DH_PANEL_VIEW_TYPE, VIEW_ID } from '../common'; +import { + DH_PANEL_VIEW_TYPE, + OPENABLE_PANEL_VARIABLE_TYPES, + VIEW_ID, +} from '../common'; import type { WorkerInfo } from '../types'; import { VSCODE_POST_MSG, @@ -9,6 +13,27 @@ import { } from '../shared'; import { getWebViewHtml } from './webViewUtils'; +/** + * Whether a variable can be opened as a Deephaven panel: its type must be one we + * know renders ({@link OPENABLE_PANEL_VARIABLE_TYPES}), and it must carry the + * `title` the embed widget URL addresses it by. Everything else — service objects + * like `AclService`, dashboards, legacy widget types — is not a panel. + * @param variable The variable (or PQ exported object) to check. + */ +export function isOpenablePanelVariable(variable: { + title?: string | null; + type?: string | null; +}): boolean { + const { title, type } = variable; + + return ( + title != null && + title !== '' && + type != null && + OPENABLE_PANEL_VARIABLE_TYPES.has(type) + ); +} + /** * Create response for login options `postMessage` request from Deephaven iframe. * @param id The id of the request. diff --git a/src/util/serverUtils.spec.ts b/src/util/serverUtils.spec.ts index f54e5384d..3af3acb88 100644 --- a/src/util/serverUtils.spec.ts +++ b/src/util/serverUtils.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import { getInitialServerStates, getPipServerUrl, + getScriptLanguageConsoleType, getServerUrlFromState, isConnectionState, parsePort, @@ -49,6 +50,29 @@ describe('getPipServerUrl', () => { }); }); +describe('getScriptLanguageConsoleType', () => { + it.each([ + ['Python', 'python'], + ['python', 'python'], + ['Groovy', 'groovy'], + ['groovy', 'groovy'], + ] as const)( + 'should map a DHE scriptLanguage to a console type: %s', + (scriptLanguage, expected) => { + expect(getScriptLanguageConsoleType(scriptLanguage)).toBe(expected); + } + ); + + it.each([[null], [undefined], [''], ['Scala']])( + 'should return undefined for an unset / unknown language: %s', + scriptLanguage => { + expect( + getScriptLanguageConsoleType(scriptLanguage as string | null) + ).toBeUndefined(); + } + ); +}); + describe('getServerUrlFromState', () => { it.each([mockServerState, mockConnectionState])( 'should return the server URL from a ServerState or ConnectionState', diff --git a/src/util/serverUtils.ts b/src/util/serverUtils.ts index 598a3ce50..755cd6ec7 100644 --- a/src/util/serverUtils.ts +++ b/src/util/serverUtils.ts @@ -42,6 +42,19 @@ export function getConsoleType( : undefined; } +/** + * Get the console type for a DHE query's `scriptLanguage` (e.g. `'Python'` / + * `'Groovy'`). DHE reports the language capitalized (it comes from the server's + * `scriptSessionProviders`), while `ConsoleType` is lower case. + * @param scriptLanguage `QueryInfo.scriptLanguage`, if known. + * @returns The console type, or undefined if the language is unset / unknown. + */ +export function getScriptLanguageConsoleType( + scriptLanguage?: string | null +): ConsoleType | undefined { + return getConsoleType(scriptLanguage?.toLowerCase()); +} + /** * Get the pip server URL for the given port. * @param port The port number to create a URL for diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 3276a751d..78aa64a79 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -1,14 +1,19 @@ import { describe, it, expect, vi } from 'vitest'; +import type * as vscode from 'vscode'; import { bitValues, boolValues, matrix } from '../testUtils'; import { + canBrowsePersistentQueryObjects, getConnectionServerTreeItem, getPanelConnectionTreeItem, getPanelVariableTreeItem, getPersistentQueryIconId, getPersistentQueryObjectLeaves, + getPersistentQueryLanguageIconId, getPersistentQueryServerTreeItem, + getPersistentQueryStatus, getPersistentQueryTreeItem, isPersistentQueryNode, + persistentQueryHasTables, getServerContextValue, getServerDescription, getServerGroupContextValue, @@ -27,6 +32,7 @@ import type { VariableType, } from '../types'; import { isInstanceOf } from './isInstanceOf'; +import { DH_PROTECTED_VARIABLE_NAMES } from '../common'; // See __mocks__/vscode.ts for the mock implementation vi.mock('vscode'); @@ -116,10 +122,47 @@ describe('getPanelVariableTreeItem', () => { type, } as VariableDefintion; - const actual = getPanelVariableTreeItem([url, variable]); + const actual = getPanelVariableTreeItem([url, variable], true); expect(actual).toMatchSnapshot(); } ); + + it('offers the delete action when the variable can be deleted', () => { + const variable = { + title: 'some title', + name: 'some_name', + type: 'Table', + } as VariableDefintion; + + expect(getPanelVariableTreeItem([url, variable], true).contextValue).toBe( + 'canDeleteDeephavenVariable' + ); + }); + + it('offers no delete action for a browse-only (PQ) object', () => { + const variable = { + title: 'some title', + name: 'some_name', + type: 'Table', + } as VariableDefintion; + + expect( + getPanelVariableTreeItem([url, variable], false).contextValue + ).toBeUndefined(); + }); + + it('offers no delete action for a protected variable name', () => { + const [protectedName] = DH_PROTECTED_VARIABLE_NAMES; + const variable = { + title: 'some title', + name: protectedName, + type: 'Table', + } as VariableDefintion; + + expect( + getPanelVariableTreeItem([url, variable], true).contextValue + ).toBeUndefined(); + }); }); describe('getServerContextValue', () => { @@ -254,18 +297,80 @@ describe('groupServers', () => { }); describe('getPersistentQueryIconId', () => { - it('returns the connected icon for a Running PQ', () => { + it('returns the filled circle for a Running PQ', () => { expect(getPersistentQueryIconId('Running')).toBe('circle-large-filled'); }); - it.each([['Stopped'], ['Initializing'], [null], [undefined]])( - 'returns the connecting/spinner icon for non-Running status: %s', + it.each([ + ['Stopped'], + ['Stopping'], + ['Failed'], + ['Error'], + ['Disconnected'], + ['Completed'], + ])('returns the stop sign for a terminal status: %s', status => { + expect(getPersistentQueryIconId(status)).toBe('circle-slash'); + }); + + it.each([[null], [undefined], ['']])( + 'returns the open circle for an unset status (not the same as stopped): %s', status => { expect(getPersistentQueryIconId(status as string | null)).toBe( - 'sync~spin' + 'circle-large-outline' ); } ); + + it.each([['Initializing'], ['Connecting'], ['Queued']])( + 'returns the spinner for a status between running and stopped: %s', + status => { + expect(getPersistentQueryIconId(status)).toBe('sync~spin'); + } + ); +}); + +describe('getPersistentQueryLanguageIconId', () => { + it.each([ + ['Python', 'dh-python'], + ['python', 'dh-python'], + ['Groovy', 'coffee'], + ['groovy', 'coffee'], + ])( + 'returns the language icon: scriptLanguage=%s', + (scriptLanguage, expected) => { + expect(getPersistentQueryLanguageIconId(scriptLanguage)).toBe(expected); + } + ); + + it.each([[null], [undefined], [''], ['Scala']])( + 'falls back to the generic worker icon when the language is unknown: %s', + scriptLanguage => { + expect( + getPersistentQueryLanguageIconId(scriptLanguage as string | null) + ).toBe('remote'); + } + ); +}); + +describe('getPersistentQueryStatus', () => { + const makeQueryInfo = ( + overrides: Record + ): Parameters[0] => + overrides as unknown as Parameters[0]; + + it('prefers the designated worker status', () => { + expect( + getPersistentQueryStatus( + makeQueryInfo({ designated: { status: 'Running' }, status: 'Stopped' }) + ) + ).toBe('Running'); + }); + + it('falls back to the config status when there is no designated worker', () => { + expect(getPersistentQueryStatus(makeQueryInfo({ status: 'Stopped' }))).toBe( + 'Stopped' + ); + }); }); describe('isPersistentQueryNode', () => { @@ -284,22 +389,228 @@ describe('isPersistentQueryNode', () => { }); describe('getPersistentQueryTreeItem', () => { - it('renders name + owner + collapsed state + context value', () => { - const node = { + /** The designated-worker endpoints a browsable PQ must have. */ + const browsableUrls = { + jsApiUrl: 'https://dhe.example.com/worker/1/jsapi/dh-core.js', + ideUrl: 'https://dhe.example.com/worker/1/ide', + }; + + function makeNode( + designated: unknown, + scriptLanguage = 'Python' + ): Parameters[0] { + return { dheServerUrl: new URL('https://dhe.example.com/'), - queryInfo: { - name: 'My PQ', - owner: 'alice', - designated: { status: 'Running' }, - }, + queryInfo: { name: 'My PQ', owner: 'alice', designated, scriptLanguage }, } as unknown as Parameters[0]; - - const item = getPersistentQueryTreeItem(node); + } + + it('renders name + owner + context value', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [{ title: 't1', type: 'Table' }], + }), + 'status' + ); expect(item.label).toBe('My PQ'); expect(item.description).toBe('alice'); expect(item.contextValue).toBe('isPersistentQuery'); - // Collapsed = 1 in the vscode mock enum. + }); + + it("renders the status circle for iconStyle 'status'", () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }), + 'status' + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-large-filled'); + }); + + it("renders the open circle for a PQ with no status (iconStyle 'status')", () => { + const item = getPersistentQueryTreeItem(makeNode(undefined), 'status'); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-large-outline'); + }); + + it("renders the stop sign for a stopped PQ (iconStyle 'status')", () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Stopped', objects: [] }), + 'status' + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('circle-slash'); + }); + + it("renders the script language icon for iconStyle 'language'", () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }, 'Groovy'), + 'language' + ); + expect((item.iconPath as vscode.ThemeIcon).id).toBe('coffee'); + }); + + it('is collapsible when the PQ exposes browsable objects (Collapsed = 1)', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: 't1', type: 'Table' }, + { title: 'f1', type: 'Figure' }, + ], + }), + 'status' + ); + expect(item.collapsibleState).toBe(1); + // Object + table counts surfaced in the tooltip (no expansion needed). + expect(item.tooltip).toBe('My PQ (Running) — 2 objects (1 table)'); + }); + + it.each([ + ['no ideUrl (e.g. a RevertHelper query)', { ideUrl: null }], + ['an empty ideUrl', { ideUrl: '' }], + ['no jsApiUrl', { jsApiUrl: null }], + ])( + 'is non-expandable when the objects cannot be opened: %s', + (_label, overrides) => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + ...overrides, + status: 'Running', + objects: [{ title: 't1', type: 'Table' }], + }), + 'status' + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe( + 'My PQ (Running) — 1 object (1 table) (worker not browsable)' + ); + } + ); + + it('ignores object entries with an empty title or type', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: '', type: 'Table' }, + { title: 't1', type: '' }, + ], + }), + 'status' + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('ignores objects whose type is not an openable panel', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [ + { title: 'd1', type: 'deephaven.ui.Dashboard' }, + { title: 'm1', type: 'TableMap' }, + { title: 'tm1', type: 'Treemap' }, + { title: 'w1', type: 'OtherWidget' }, + { title: 'acl', type: 'AclService' }, + ], + }), + 'status' + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('counts deephaven.ui panels as openable objects', () => { + const item = getPersistentQueryTreeItem( + makeNode({ + ...browsableUrls, + status: 'Running', + objects: [{ title: 'ui1', type: 'deephaven.ui.Element' }], + }), + 'status' + ); expect(item.collapsibleState).toBe(1); + expect(item.tooltip).toBe('My PQ (Running) — 1 object'); + }); + + it('is non-expandable when the PQ exposes no objects (None = 0)', () => { + const item = getPersistentQueryTreeItem( + makeNode({ status: 'Running', objects: [] }), + 'status' + ); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ (Running) — no objects'); + }); + + it('treats a PQ with no designated worker as non-expandable', () => { + const item = getPersistentQueryTreeItem(makeNode(undefined), 'status'); + expect(item.collapsibleState).toBe(0); + expect(item.tooltip).toBe('My PQ — no objects'); + }); +}); + +describe('canBrowsePersistentQueryObjects', () => { + const makeQueryInfo = ( + designated: unknown + ): Parameters[0] => + ({ designated }) as unknown as Parameters< + typeof canBrowsePersistentQueryObjects + >[0]; + + it('is true when the designated worker has both endpoints', () => { + expect( + canBrowsePersistentQueryObjects( + makeQueryInfo({ + jsApiUrl: 'https://w/jsapi/dh-core.js', + ideUrl: 'https://w/ide', + }) + ) + ).toBe(true); + }); + + it.each([ + [undefined], + [{ jsApiUrl: 'https://w/jsapi/dh-core.js', ideUrl: null }], + [{ jsApiUrl: 'https://w/jsapi/dh-core.js', ideUrl: '' }], + [{ jsApiUrl: null, ideUrl: 'https://w/ide' }], + [{ jsApiUrl: '', ideUrl: 'https://w/ide' }], + ])('is false without both endpoints: %s', designated => { + expect(canBrowsePersistentQueryObjects(makeQueryInfo(designated))).toBe( + false + ); + }); +}); + +describe('persistentQueryHasTables', () => { + const makeQueryInfo = ( + objects: unknown + ): Parameters[0] => + ({ designated: { objects } }) as unknown as Parameters< + typeof persistentQueryHasTables + >[0]; + + it('returns true when a table-typed object is present', () => { + expect( + persistentQueryHasTables( + makeQueryInfo([ + { title: 'f1', type: 'Figure' }, + { title: 't1', type: 'TreeTable' }, + ]) + ) + ).toBe(true); + }); + + it('returns false when only non-table objects are present', () => { + expect( + persistentQueryHasTables(makeQueryInfo([{ title: 'f1', type: 'Figure' }])) + ).toBe(false); + }); + + it('returns false when there are no objects', () => { + expect(persistentQueryHasTables(makeQueryInfo([]))).toBe(false); }); }); @@ -313,6 +624,8 @@ describe('getPersistentQueryServerTreeItem', () => { const item = getPersistentQueryServerTreeItem(server); expect(item.label).toBe('DHE'); expect(item.contextValue).toBe('isPersistentQueryServer'); + // Server icon, matching the Workers / Panels tree server nodes. + expect((item.iconPath as vscode.ThemeIcon).id).toBe('vm-connect'); // Expanded = 2 in the vscode mock enum. expect(item.collapsibleState).toBe(2); }); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 4ded34870..2396bb522 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -15,11 +15,14 @@ import { CONNECTION_TREE_ITEM_CONTEXT, DH_PROTECTED_VARIABLE_NAMES, ICON_ID, + isTerminalQueryStatus, OPEN_VARIABLE_PANELS_CMD, PERSISTENT_QUERY_TREE_ITEM_CONTEXT, SERVER_TREE_ITEM_CONTEXT, type ServerTreeItemContextValue, } from '../common'; +import { isOpenablePanelVariable } from './panelUtils'; +import { getScriptLanguageConsoleType } from './serverUtils'; /** * Get a tree item vscode.ThemeIcon for a variable type. @@ -54,9 +57,11 @@ export function getVariableIconPath( } /** - * Get the icon id for a console type / language, used for connection tree nodes. - * Falls back to the generic "connected" icon when the console type is unknown - * (e.g. a plain DHC connection or one whose console type has not resolved yet). + * Get the icon id for a console type / language, used for worker (connection / + * persistent query) tree nodes. Falls back to the generic worker icon when the + * console type is unknown (e.g. a plain DHC connection or one whose console type + * has not resolved yet) — never the server icon, so a worker node is always + * distinguishable from its parent server. * @param consoleType Console type (language) of the connection, if known. * @returns Icon id from `ICON_ID`. */ @@ -69,7 +74,7 @@ export function getConsoleTypeIconId( case 'groovy': return ICON_ID.groovy; default: - return ICON_ID.connected; + return ICON_ID.worker; } } @@ -103,21 +108,27 @@ export async function getPanelConnectionTreeItem( /** * Get `TreeItem` for a panel variable. - * @param variable + * @param variable The worker URL + variable to render. + * @param canDelete Whether the node offers the delete (trash) action. Only + * variables living in a console session can be deleted — a persistent query's + * exported objects are browse-only, so the action must not be offered for them + * (`ExtensionController.onDeleteVariable` would ignore it anyway, leaving a + * button that does nothing). */ -export function getPanelVariableTreeItem([url, variable]: [ - URL, - VariableDefintion, -]): vscode.TreeItem { +export function getPanelVariableTreeItem( + [url, variable]: [URL, VariableDefintion], + canDelete: boolean +): vscode.TreeItem { const iconPath = getVariableIconPath(variable.type); const variablesToOpen: NonEmptyArray = [variable]; return { label: variable.title, iconPath, - contextValue: DH_PROTECTED_VARIABLE_NAMES.has(variable.name) - ? undefined - : 'canDeleteDeephavenVariable', + contextValue: + canDelete && !DH_PROTECTED_VARIABLE_NAMES.has(variable.name) + ? 'canDeleteDeephavenVariable' + : undefined, command: { title: 'Open Panel', command: OPEN_VARIABLE_PANELS_CMD, @@ -127,34 +138,172 @@ export function getPanelVariableTreeItem([url, variable]: [ } /** - * Get the icon for a persistent query based on its (designated worker) status. - * `Running` PQs — the only ones this tree lists — show the connected icon; any - * transitional/other status shows a spinner. Mirrors the connection tree's - * running/connecting icon convention. - * @param status The `queryInfo.designated?.status` value. + * How a persistent-query node draws its icon. Each tree picks the vocabulary + * that keeps it internally consistent: + * - `'status'` (Persistent Queries tree): the Servers-tree circles, since that + * view is about PQ lifecycle and lists PQs of every status. + * - `'language'` (Panels tree): the script-language icon, matching the console + * worker nodes a PQ sits alongside there. + */ +export type PersistentQueryIconStyle = 'status' | 'language'; + +/** + * Get the status icon for a persistent query, using the same circle vocabulary + * as the Servers tree: + * - `Running` → filled circle. + * - terminal (`Stopped`, `Failed`, …) → stop sign. + * - unset → open circle. An unknown status is NOT the same as a stopped one: a + * PQ can be listed with no status yet (no `designated` block), and claiming it + * stopped would be wrong. A spinner would be equally wrong — nothing is known + * to be in progress. + * - anything else (`Initializing`, `Connecting`, …) → spinner. + * @param status The PQ status (`designated.status`, falling back to `status`). + */ +export function getPersistentQueryIconId( + status: string | null | undefined +): string { + if (status === 'Running') { + return ICON_ID.serverConnected; + } + + if (status == null || status === '') { + return ICON_ID.serverRunning; + } + + if (isTerminalQueryStatus(status)) { + return ICON_ID.serverStopped; + } + + return ICON_ID.connecting; +} + +/** + * Get the script-language icon for a persistent query — the same icon its + * console-worker siblings use in the Panels tree. Falls back to the generic + * worker icon when the language is unset / unrecognized. + * @param scriptLanguage The `queryInfo.scriptLanguage` value (e.g. `'Python'`). + */ +export function getPersistentQueryLanguageIconId( + scriptLanguage?: string | null +): string { + return getConsoleTypeIconId(getScriptLanguageConsoleType(scriptLanguage)); +} + +/** + * The status of a persistent query. Prefers the designated worker's live status + * and falls back to the (deprecated) config-level status, which is the only one + * available for a PQ with no designated worker (e.g. never started / stopped). + * @param queryInfo The PQ whose status to read. + */ +export function getPersistentQueryStatus( + queryInfo: QueryInfo +): string | null | undefined { + return queryInfo.designated?.status ?? queryInfo.status; +} + +/** Variable types that render as tables (mirrors the table icon set). */ +const TABLE_VARIABLE_TYPES: ReadonlySet = new Set([ + 'Table', + 'TableMap', + 'TreeTable', + 'HierarchicalTable', + 'PartitionedTable', +]); + +/** + * The exported objects of a PQ, read straight from `designated.objects` — no + * worker connection or node expansion required — filtered to the named + typed + * entries that render as object leaves. This is the same set + * {@link getPersistentQueryObjectLeaves} produces, so a caller can cheaply tell + * whether a PQ has any (or any table-typed) objects before expanding it. + * @param queryInfo The PQ whose exported objects to read. + */ +export function getPersistentQueryObjects( + queryInfo: QueryInfo +): VariableDefintion[] { + const objects = queryInfo.designated?.objects ?? []; + // Only objects that can actually open as a panel: counting an unopenable one + // would put an expander on a PQ whose children open onto nothing. + return objects.filter((obj): obj is VariableDefintion => + isOpenablePanelVariable(obj) + ); +} + +/** + * Whether a PQ's exported objects can actually be opened. The tree opens them + * through a browse connection derived from the designated worker + * (`getWorkerInfoFromQueryInfo`), which requires both `jsApiUrl` and `ideUrl`. + * Helper / system queries (e.g. `RevertHelper`) can be `Running` and report + * objects while having no IDE endpoint (`designated.ideUrl` is nullable), so + * without this check they render an expander that can never produce children. + * @param queryInfo The PQ to check. */ -export function getPersistentQueryIconId(status: string | null | undefined): string { - return status === 'Running' ? ICON_ID.serverConnected : ICON_ID.connecting; +export function canBrowsePersistentQueryObjects(queryInfo: QueryInfo): boolean { + const { designated } = queryInfo; + + return ( + designated != null && + designated.jsApiUrl != null && + designated.jsApiUrl !== '' && + designated.ideUrl != null && + designated.ideUrl !== '' + ); +} + +/** + * Whether a PQ exposes at least one table-typed object, determined from + * `designated.objects` without connecting to or expanding the PQ. + * @param queryInfo The PQ to check. + */ +export function persistentQueryHasTables(queryInfo: QueryInfo): boolean { + return getPersistentQueryObjects(queryInfo).some(obj => + TABLE_VARIABLE_TYPES.has(obj.type) + ); } /** - * Get `TreeItem` for a persistent-query node in the Persistent Queries tree. - * The node is collapsible (its children are the PQ's exported objects) and - * carries a status icon + the PQ name. + * Get `TreeItem` for a persistent-query node. The node carries the PQ name plus + * the icon its host tree calls for ({@link PersistentQueryIconStyle}), and is + * collapsible only when the PQ exposes objects *and* those objects can be opened + * ({@link canBrowsePersistentQueryObjects}) — otherwise it renders + * non-expandable so the tree never opens onto an empty list (both are known + * cheaply from `designated`, no connection required). * @param node The persistent-query node. + * @param iconStyle Which icon vocabulary the host tree uses. */ export function getPersistentQueryTreeItem( - node: PersistentQueryNode + node: PersistentQueryNode, + iconStyle: PersistentQueryIconStyle ): vscode.TreeItem { const { queryInfo } = node; - const status = queryInfo.designated?.status; + const status = getPersistentQueryStatus(queryInfo); + + const objects = getPersistentQueryObjects(queryInfo); + const tableCount = objects.filter(obj => + TABLE_VARIABLE_TYPES.has(obj.type) + ).length; + const canBrowse = canBrowsePersistentQueryObjects(queryInfo); + + const plural = (n: number, noun: string): string => + `${n} ${noun}${n === 1 ? '' : 's'}`; + const countSuffix = + objects.length === 0 + ? ' — no objects' + : ` — ${plural(objects.length, 'object')}${tableCount > 0 ? ` (${plural(tableCount, 'table')})` : ''}${canBrowse ? '' : ' (worker not browsable)'}`; return { label: queryInfo.name, description: queryInfo.owner ?? undefined, - tooltip: `${queryInfo.name}${status == null ? '' : ` (${status})`}`, - iconPath: new vscode.ThemeIcon(getPersistentQueryIconId(status)), - collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + tooltip: `${queryInfo.name}${status == null ? '' : ` (${status})`}${countSuffix}`, + iconPath: new vscode.ThemeIcon( + iconStyle === 'language' + ? getPersistentQueryLanguageIconId(queryInfo.scriptLanguage) + : getPersistentQueryIconId(status) + ), + collapsibleState: + objects.length > 0 && canBrowse + ? vscode.TreeItemCollapsibleState.Collapsed + : vscode.TreeItemCollapsibleState.None, contextValue: PERSISTENT_QUERY_TREE_ITEM_CONTEXT.isPersistentQuery, }; } @@ -177,12 +326,13 @@ export function getPersistentQueryServerTreeItem( } /** - * Type guard distinguishing a persistent-query node from a server node in the - * Persistent Queries tree. A `PersistentQueryNode` carries a `queryInfo`. + * Type guard distinguishing a persistent-query node from a server or connection + * node (both the Persistent Queries and Panels trees mix them). A + * `PersistentQueryNode` carries a `queryInfo`. * @param node The node to check. */ export function isPersistentQueryNode( - node: ServerState | PersistentQueryNode + node: ServerState | ConnectionState | PersistentQueryNode ): node is PersistentQueryNode { return (node as PersistentQueryNode).queryInfo != null; } @@ -199,11 +349,7 @@ export function getPersistentQueryObjectLeaves( workerUrl: URL, queryInfo: QueryInfo ): [URL, VariableDefintion][] { - const objects = queryInfo.designated?.objects ?? []; - - return objects - .filter(obj => obj.title != null && obj.type != null) - .map(obj => [workerUrl, obj as VariableDefintion]); + return getPersistentQueryObjects(queryInfo).map(obj => [workerUrl, obj]); } /** @@ -275,8 +421,6 @@ export function getConnectionServerTreeItem( ): vscode.TreeItem { return { label: getConnectionServerLabel(server), - // The "computer" icon (`vm-connect`) previously used for worker connection - // nodes, before the language (Python/Groovy) icons took their place. iconPath: new vscode.ThemeIcon(ICON_ID.connected), collapsibleState: vscode.TreeItemCollapsibleState.Expanded, contextValue: From 322b93d29374385dc49d74e1ee141f31c03edceb Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Thu, 13 Aug 2026 17:12:49 -0500 Subject: [PATCH 30/68] return early if ideUrl isn't defined instead of failing (#DH-22593) --- src/dh/dhe.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/dh/dhe.ts b/src/dh/dhe.ts index 53f7f1981..bea64696e 100644 --- a/src/dh/dhe.ts +++ b/src/dh/dhe.ts @@ -42,7 +42,7 @@ import { UnsupportedFeatureQueryError, } from '../common'; import { withResolvers } from '../util'; -import { assertDefined, type QuerySerial } from '../shared'; +import { type QuerySerial } from '../shared'; export type IDraftQuery = EditableQueryInfo & { isClientSide: boolean; @@ -467,15 +467,10 @@ export function getWorkerInfoFromQueryInfo( tagId: UniqueID, queryInfo: QueryInfo ): WorkerInfo | undefined { - if (queryInfo.designated == null) { + if (queryInfo.designated == null || queryInfo.designated.ideUrl == null) { return; } - assertDefined( - queryInfo.designated.ideUrl, - 'designated.ideUrl must be defined' - ); - const { envoyPrefix, grpcUrl, ideUrl, jsApiUrl, processInfoId, workerName } = queryInfo.designated; From aa445a9d67687fcab47a71a3d21976b0d4b8927a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 24 Aug 2026 15:51:02 -0500 Subject: [PATCH 31/68] removed query status fallback (#DH-22593) --- src/util/treeViewUtils.spec.ts | 10 ++++------ src/util/treeViewUtils.ts | 6 ++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/util/treeViewUtils.spec.ts b/src/util/treeViewUtils.spec.ts index 78aa64a79..1dfd664d8 100644 --- a/src/util/treeViewUtils.spec.ts +++ b/src/util/treeViewUtils.spec.ts @@ -358,18 +358,16 @@ describe('getPersistentQueryStatus', () => { ): Parameters[0] => overrides as unknown as Parameters[0]; - it('prefers the designated worker status', () => { + it('returns the designated worker status', () => { expect( getPersistentQueryStatus( - makeQueryInfo({ designated: { status: 'Running' }, status: 'Stopped' }) + makeQueryInfo({ designated: { status: 'Running' } }) ) ).toBe('Running'); }); - it('falls back to the config status when there is no designated worker', () => { - expect(getPersistentQueryStatus(makeQueryInfo({ status: 'Stopped' }))).toBe( - 'Stopped' - ); + it('returns undefined when there is no designated worker', () => { + expect(getPersistentQueryStatus(makeQueryInfo({}))).toBeUndefined(); }); }); diff --git a/src/util/treeViewUtils.ts b/src/util/treeViewUtils.ts index 2396bb522..74ac2f06c 100644 --- a/src/util/treeViewUtils.ts +++ b/src/util/treeViewUtils.ts @@ -190,15 +190,13 @@ export function getPersistentQueryLanguageIconId( } /** - * The status of a persistent query. Prefers the designated worker's live status - * and falls back to the (deprecated) config-level status, which is the only one - * available for a PQ with no designated worker (e.g. never started / stopped). + * Get the the status of a persistent query. * @param queryInfo The PQ whose status to read. */ export function getPersistentQueryStatus( queryInfo: QueryInfo ): string | null | undefined { - return queryInfo.designated?.status ?? queryInfo.status; + return queryInfo.designated?.status; } /** Variable types that render as tables (mirrors the table icon set). */ From ff53188742ce240e9a641f4029508e976f2bdd51 Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Mon, 24 Aug 2026 18:06:52 -0500 Subject: [PATCH 32/68] Increased node transport window sizes to fix connection performance (#DH-22593) --- package-lock.json | 52 +++++----- package.json | 8 +- src/common/constants.ts | 1 + src/controllers/ExtensionController.ts | 18 +++- src/controllers/UserLoginController.ts | 26 ++++- src/services/DheService.ts | 27 +++++- src/services/PersistentQueryService.ts | 7 +- src/services/ServerManager.ts | 13 +++ src/util/connectDiagnostics.ts | 128 +++++++++++++++++++++++++ src/util/index.ts | 2 + src/util/transportUtils.spec.ts | 57 +++++++++++ src/util/transportUtils.ts | 78 +++++++++++++++ 12 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 src/util/connectDiagnostics.ts create mode 100644 src/util/transportUtils.spec.ts create mode 100644 src/util/transportUtils.ts diff --git a/package-lock.json b/package-lock.json index d5f6684a9..f42fe6188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@deephaven-enterprise/jsapi-manager": "^2026.1.48", "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", "@deephaven-enterprise/query-utils": "^2026.1.48", - "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-nodejs": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", @@ -677,14 +677,14 @@ } }, "node_modules/@deephaven/jsapi-nodejs": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.15.0.tgz", - "integrity": "sha512-41er+fe0hvhaVjuz8/FGctMee0XZUXbnBCjidoveA5o5LeEfYE5yLTxDd3kRqIlwu6g26VKn4xq2W8HYnK4n3w==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-bR/7aEtmbXgPKKYUOmCE9I6Wz2xnhJSNUbfBozhfcghprXcMihFPZi3JJbGmk4MhSPSBjvNEgPBTNvKO97OQrQ==", "license": "Apache-2.0", "dependencies": { "@deephaven/jsapi-types": "^1.0.0-dev0.40.4", - "@deephaven/log": "^1.8.0", - "@deephaven/utils": "^1.10.0", + "@deephaven/log": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", + "@deephaven/utils": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", "ws": "^8.18.0" }, "engines": { @@ -698,9 +698,9 @@ "license": "Apache-2.0" }, "node_modules/@deephaven/jsapi-nodejs/node_modules/@deephaven/log": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-1.8.0.tgz", - "integrity": "sha512-gzp6/7qW4W8Re+DLSaG33KEQJ30OrrNq3cNQA8fUeXQrabSNOIsyeVOaerQ/57c4zWhWVKamplax0LIYRsmDiw==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-f8W4NkXKQIQxeFGrh3Hj/KdvkN/0kCMWVyWnyaZVG7jmP045iixgfJIyTlCynsNICga0MpGPs9M+9XuZhfjIPA==", "license": "Apache-2.0", "dependencies": { "event-target-shim": "^6.0.2", @@ -712,12 +712,12 @@ } }, "node_modules/@deephaven/jsapi-nodejs/node_modules/@deephaven/utils": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-1.10.0.tgz", - "integrity": "sha512-KLs73wIU/T3ZA+H+YTlzQ1fT+6p02RfixMQ7+l8S+IyLxc+nwSMABYnoZybJJuRvw1huJuFv1+n0B0HDteDFZA==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-7ELa7sphHaexj8a7fayK07SoA1id2YJt5c2AvkSj9agdpZFwR2HEuhD/ROAW6TwqymjAoRg98vJL2wbM1/YH5g==", "license": "Apache-2.0", "dependencies": { - "@deephaven/log": "^1.8.0", + "@deephaven/log": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", "nanoid": "^5.0.7" }, "engines": { @@ -15128,7 +15128,7 @@ "@deephaven-enterprise/jsapi-manager": "2026.1.48", "@deephaven-enterprise/jsapi-types": "2026.1.48", "@deephaven-enterprise/query-utils": "2026.1.48", - "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-nodejs": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", "@deephaven/jsapi-types": "1.0.0-dev0.40.5", "@deephaven/utils": "^0.97.0" }, @@ -15183,13 +15183,13 @@ "integrity": "sha512-SMWKYT8aFtZ/CyVUtUiF1/RPQGl4Y6dvNG43KvmIMKKMXYqhliKD291aynYSl7C8IafkHNzdDtdAZPgPXLoOWA==" }, "@deephaven/jsapi-nodejs": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.15.0.tgz", - "integrity": "sha512-41er+fe0hvhaVjuz8/FGctMee0XZUXbnBCjidoveA5o5LeEfYE5yLTxDd3kRqIlwu6g26VKn4xq2W8HYnK4n3w==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/jsapi-nodejs/-/jsapi-nodejs-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-bR/7aEtmbXgPKKYUOmCE9I6Wz2xnhJSNUbfBozhfcghprXcMihFPZi3JJbGmk4MhSPSBjvNEgPBTNvKO97OQrQ==", "requires": { "@deephaven/jsapi-types": "^1.0.0-dev0.40.4", - "@deephaven/log": "^1.8.0", - "@deephaven/utils": "^1.10.0", + "@deephaven/log": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", + "@deephaven/utils": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", "ws": "^8.18.0" }, "dependencies": { @@ -15199,9 +15199,9 @@ "integrity": "sha512-NwMxFmNCnRV4/2+MdN/8vUGiEtXFgL1K/+iXTKKvi+Brje5JHOSCn2miCKR9tAn0LNb/UdmJq+DSIZqvz8cU/Q==" }, "@deephaven/log": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-1.8.0.tgz", - "integrity": "sha512-gzp6/7qW4W8Re+DLSaG33KEQJ30OrrNq3cNQA8fUeXQrabSNOIsyeVOaerQ/57c4zWhWVKamplax0LIYRsmDiw==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/log/-/log-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-f8W4NkXKQIQxeFGrh3Hj/KdvkN/0kCMWVyWnyaZVG7jmP045iixgfJIyTlCynsNICga0MpGPs9M+9XuZhfjIPA==", "requires": { "event-target-shim": "^6.0.2", "jszip": "^3.10.1", @@ -15209,11 +15209,11 @@ } }, "@deephaven/utils": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-1.10.0.tgz", - "integrity": "sha512-KLs73wIU/T3ZA+H+YTlzQ1fT+6p02RfixMQ7+l8S+IyLxc+nwSMABYnoZybJJuRvw1huJuFv1+n0B0HDteDFZA==", + "version": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", + "resolved": "https://registry.npmjs.org/@deephaven/utils/-/utils-1.27.5-alpha-nodehttp2-config.98bcfa9.0.tgz", + "integrity": "sha512-7ELa7sphHaexj8a7fayK07SoA1id2YJt5c2AvkSj9agdpZFwR2HEuhD/ROAW6TwqymjAoRg98vJL2wbM1/YH5g==", "requires": { - "@deephaven/log": "^1.8.0", + "@deephaven/log": "^1.27.5-alpha-nodehttp2-config.98bcfa9.0+98bcfa9", "nanoid": "^5.0.7" } } diff --git a/package.json b/package.json index 5ca57db76..0a4ebe22d 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,11 @@ "http://localhost:10000/" ] }, + "deephaven.diagnostics.connectTiming": { + "description": "Log detailed connection timing and HTTP/2 transport diagnostics to the Deephaven Debug output channel. Useful for diagnosing slow server connections. Transport metrics require a window reload to take effect.", + "type": "boolean", + "default": false + }, "deephaven.enterpriseServers": { "description": "List of Deephaven Enterprise servers that the extension can connect to.", "type": "array", @@ -1175,7 +1180,7 @@ "@deephaven-enterprise/jsapi-manager": "^2026.1.48", "@deephaven-enterprise/jsapi-nodejs": "^2026.1.48", "@deephaven-enterprise/query-utils": "^2026.1.48", - "@deephaven/jsapi-nodejs": "^1.15.0", + "@deephaven/jsapi-nodejs": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", "@deephaven/jsapi-utils": "^1.16.0", "@modelcontextprotocol/sdk": "^1.27.1", "archiver": "^7.0.1", @@ -1222,6 +1227,7 @@ "vscode-extension-tester": "^8.21.0" }, "overrides": { + "@deephaven/jsapi-nodejs": "1.27.5-alpha-nodehttp2-config.98bcfa9.0", "undici": "^6.24.0", "diff": "^8.0.3", "serialize-javascript": "^7.0.3" diff --git a/src/common/constants.ts b/src/common/constants.ts index 2fc49ca84..7a5b4461b 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -15,6 +15,7 @@ export const EXTENSION_ID = 'vscode-deephaven' as const; export const CONFIG_KEY = { root: 'deephaven', coreServers: 'coreServers', + diagnosticsConnectTiming: 'diagnostics.connectTiming', enterpriseServers: 'enterpriseServers', importPrefixes: 'importPrefixes', mcpAutoUpdateConfig: 'mcp.autoUpdateConfig', diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index c7b2624a2..aaeaa8a98 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -45,11 +45,13 @@ import { type ViewID, } from '../common'; import { + createConnectTimer, createExtensionInfo, deserializeRange, getEditorForUri, getGroovyTopLevelPackageName, getPythonTopLevelModuleFullname, + getSharedTransportFactory, getTempDir, isInstanceOf, isSerializedRange, @@ -603,7 +605,7 @@ export class ExtensionController implements IDisposable { // so that we can provide a working NodeJS version of http2. transportFactory: isElectronFetchEnabled ? undefined - : NodeHttp2gRPCTransport.factory, + : getSharedTransportFactory(), }; if (envoyPrefix != null) { @@ -632,8 +634,16 @@ export class ExtensionController implements IDisposable { url: URL ): Promise => { assertDefined(this._dheJsApiCache, 'dheJsApiCache'); + + // On a cache miss this downloads ~5MB of `irisapi.nocache.js`, writes it + // synchronously, and `require`s it — all on the extension host thread, and + // all inside the server tree's "connecting" spinner. Timed separately from + // the websocket connect below so the two aren't conflated. + const doneJsApi = createConnectTimer(); const dhe = await this._dheJsApiCache.get(url); + doneJsApi('dheJsApiCache.get'); + const doneConnect = createConnectTimer(); const client: DheUnauthenticatedClient = await createDheClient( dhe, // While gplus forward will normalize the URL, Grizzly still requires @@ -644,9 +654,13 @@ export class ExtensionController implements IDisposable { // should just be ignored if provided. We don't really have a good way // to determine if the server supports it or not, and gplus and beyond // require it on non-envoy servers, so we just always provide it. - transportFactory: NodeHttp2gRPCTransport.factory, + // + // The shared factory carries the HTTP/2 window sizes and, when + // diagnostics are on, upstream's native session/stream metrics. + transportFactory: getSharedTransportFactory(), } ); + doneConnect('createDheClient (connect)'); const wResolvers = withResolvers(); let { promise: refreshTokenSerializedPromise } = wResolvers; diff --git a/src/controllers/UserLoginController.ts b/src/controllers/UserLoginController.ts index 33d397610..9cbfad571 100644 --- a/src/controllers/UserLoginController.ts +++ b/src/controllers/UserLoginController.ts @@ -15,6 +15,7 @@ import { GENERATE_DHE_KEY_PAIR_CMD, } from '../common'; import { + createConnectTimer, Logger, promptForPsk, promptForAuthFlow, @@ -296,8 +297,16 @@ export class UserLoginController extends ControllerBase { serverUrl: URL, operateAsAnotherUser: boolean ): Promise => { + // Everything in this handler runs inside the server tree's "connecting" + // spinner, so each step is timed to show where a slow connect actually goes. + // No-ops unless `deephaven.diagnostics.connectTiming` is enabled. + const doneFactory = createConnectTimer(); const dheClient = await this.dheClientFactory(serverUrl); + doneFactory('dheClientFactory'); + + const doneAuthConfig = createConnectTimer(); const authConfig = await getDheAuthConfig(dheClient.client, logger); + doneAuthConfig('getDheAuthConfig'); if (isNoAuthConfig(authConfig)) { this.toast.info('No authentication methods configured.'); @@ -332,12 +341,16 @@ export class UserLoginController extends ControllerBase { const privateKeyUserNames = Object.keys(secretKeys) as Username[]; + // Interactive — this is user typing time, not server time. Timed only so + // it can be subtracted from the total. + const donePrompt = createConnectTimer(); credentials = await promptForCredentials({ title, userLoginPreferences, privateKeyUserNames, showOperateAs: operateAsAnotherUser, }); + donePrompt('promptForCredentials (user input)'); // Cancelled by user if (credentials == null) { @@ -355,6 +368,10 @@ export class UserLoginController extends ControllerBase { }, }); + // `IrisClient.login` does not resolve until the initial query config + // batch has been received from the controller (`handleLoggedIn` awaits + // `controllerConfig`), so this step scales with the server's PQ count. + const doneLogin = createConnectTimer(); authenticatedClient = credentials.type === 'password' ? await loginClientWrapper( @@ -368,9 +385,16 @@ export class UserLoginController extends ControllerBase { username ], }); + doneLogin('login'); } - if (!(await hasInteractivePermission(authenticatedClient.client))) { + const donePermission = createConnectTimer(); + const isInteractive = await hasInteractivePermission( + authenticatedClient.client + ); + donePermission('hasInteractivePermission'); + + if (!isInteractive) { throw new Error('User does not have interactive permissions.'); } diff --git a/src/services/DheService.ts b/src/services/DheService.ts index ae547200a..223f35d50 100644 --- a/src/services/DheService.ts +++ b/src/services/DheService.ts @@ -9,7 +9,6 @@ import type { } from '@deephaven-enterprise/client-utils'; import { EnterpriseCorePlusManager } from '@deephaven-enterprise/jsapi-manager'; import { createJsApiFactories } from '@deephaven-enterprise/jsapi-nodejs'; -import { NodeHttp2gRPCTransport } from '@deephaven/jsapi-nodejs'; import { type ConsoleType, type DheAuthenticatedClientWrapper, @@ -26,6 +25,8 @@ import { type WorkerURL, } from '../types'; import { + createConnectTimer, + getSharedTransportFactory, getTempDir, Logger, uniqueId, @@ -164,23 +165,38 @@ export class DheService implements IDheService { private _initClient = async ( operateAsAnotherUser: boolean ): Promise => { + // This whole method runs inside the server tree's "connecting" spinner + // (`ServerManager._pendingServerConnections`), so each step is timed to show + // where a slow connect actually goes. No-ops unless + // `deephaven.diagnostics.connectTiming` is enabled. + const doneTotal = createConnectTimer(); + if (!this._dheClientCache.has(this.serverUrl)) { + const done = createConnectTimer(); await vscode.commands.executeCommand( CREATE_DHE_AUTHENTICATED_CLIENT_CMD, this.serverUrl, operateAsAnotherUser ); + done('createAuthenticatedClient'); } const maybeClient = await this._dheClientCache.get(this.serverUrl); if (maybeClient != null) { + const doneUserInfo = createConnectTimer(); const userInfo = await maybeClient.client.getUserInfo(); + doneUserInfo('getUserInfo'); + this._operateAs = userInfo.operateAs; + + const doneSubscribe = createConnectTimer(); await this._subscribeToWorkerEvents(maybeClient); + doneSubscribe('subscribeToWorkerEvents'); } if (!this._dheServerFeaturesCache.has(this.serverUrl)) { + const doneFeatures = createConnectTimer(); try { const features = await getDheFeatures(this.serverUrl); this._dheServerFeaturesCache.set(this.serverUrl, features); @@ -192,9 +208,16 @@ export class DheService implements IDheService { } else { logger.error('Failed to get DHE server features', err); } + } finally { + // `getDheFeatures` is an unbounded `fetch` (no timeout / abort signal), + // so time it on the failure path too — a hung request looks identical to + // a slow one from here. + doneFeatures('getDheFeatures'); } } + doneTotal('_initClient total'); + return maybeClient ?? null; }; @@ -433,7 +456,7 @@ export class DheService implements IDheService { storageDir: getTempDir({ subDirectory: urlToDirectoryName(this.serverUrl), }), - transportFactory: NodeHttp2gRPCTransport.factory, + transportFactory: getSharedTransportFactory(), }); return new EnterpriseCorePlusManager( diff --git a/src/services/PersistentQueryService.ts b/src/services/PersistentQueryService.ts index 78056a7b5..ee8fcbe3c 100644 --- a/src/services/PersistentQueryService.ts +++ b/src/services/PersistentQueryService.ts @@ -198,9 +198,12 @@ export class PersistentQueryService // the tree, so surface it rather than dropping it quietly. const unresolved = [...serials].filter(serial => !knownSerials.has(serial)); if (unresolved.length > 0) { + // Capped: this fires on every tree refresh, and a server can hold tens of + // thousands of queries — stringifying the whole array each time would cost + // more than the lookup it is reporting on. logger.debug( - `Table serials with no known config for ${serverUrl.href}:`, - unresolved + `Table serials with no known config for ${serverUrl.href}: ${unresolved.length} total,`, + unresolved.slice(0, 20) ); } diff --git a/src/services/ServerManager.ts b/src/services/ServerManager.ts index 690eee688..347f1f749 100644 --- a/src/services/ServerManager.ts +++ b/src/services/ServerManager.ts @@ -29,7 +29,9 @@ import { getInitialServerStates, isDisposable, isInstanceOf, + isConnectTimingEnabled, Logger, + startEventLoopLagSampler, uniqueId, URIMap, URLMap, @@ -291,6 +293,16 @@ export class ServerManager implements IServerManager { this._pendingConnectionMap.set(serverUrl, withResolvers()); this._onDidUpdate.fire(); + // Covers the whole connect — the server tree's "connecting" spinner (which + // clears at `_resolvePendingServerConnection`, partway through) plus worker + // attach. Sampling event loop lag across it says whether a slow connect is + // the extension host working or waiting, which is the measurement that + // distinguishes a client-side bottleneck from an upstream one. Opt-in via + // `deephaven.diagnostics.connectTiming`. + const stopLagSampler = isConnectTimingEnabled() + ? startEventLoopLagSampler(`connectToServer ${serverUrl.href}`) + : null; + let firstConnection: ConnectionState | null = null; try { @@ -321,6 +333,7 @@ export class ServerManager implements IServerManager { return firstConnection; } } finally { + stopLagSampler?.(); this._resolvePendingConnection(serverUrl, firstConnection); } diff --git a/src/util/connectDiagnostics.ts b/src/util/connectDiagnostics.ts new file mode 100644 index 000000000..dc5499428 --- /dev/null +++ b/src/util/connectDiagnostics.ts @@ -0,0 +1,128 @@ +import * as vscode from 'vscode'; +import { CONFIG_KEY } from '../common'; +import { Logger } from './Logger'; + +/** + * Opt-in connect diagnostics, gated behind the + * `deephaven.diagnostics.connectTiming` setting (off by default) and written to + * the Deephaven Debug output channel. + * + * These exist because a slow connect is not self-explanatory from the outside: a + * DHE server holding 20k persistent queries took 31.4s to connect, and + * distinguishing "the extension host is busy" from "the extension host is + * waiting on the network" was the measurement that identified the cause (a + * 64KB HTTP/2 receive window capping the controller stream at ~425 KB/s). The + * event loop was 1.15% blocked — idle — which ruled out every client-side + * explanation at once. + * + * Per-stream throughput is NOT measured here — `@deephaven/jsapi-nodejs` reports + * it natively via `onStreamMetrics` (wired up in `transportUtils`), including a + * `consumerTimeMs` that separates a slow connection from a slow consumer. What + * remains is the event-loop lag sampler, which answers a question the transport + * cannot see, and the connect-path phase timers. + * + * Callers must check {@link isConnectTimingEnabled} before doing any timing + * work, so that these cost nothing when the setting is off. + */ + +const logger = new Logger('ConnectDiagnostics'); + +/** + * Whether opt-in connect diagnostics are enabled + * (`deephaven.diagnostics.connectTiming`, default `false`). + * + * Read at each call site rather than cached so toggling the setting takes effect + * on the next connect without reloading the window. + */ +export function isConnectTimingEnabled(): boolean { + return ( + vscode.workspace + .getConfiguration(CONFIG_KEY.root) + .get(CONFIG_KEY.diagnosticsConnectTiming, false) === true + ); +} + +/** Shared no-op so a disabled timer allocates nothing per call. */ +const noopTimer = (): void => {}; + +/** + * Start a phase timer for the connect path. Call the returned function when the + * phase completes to log its duration. + * + * Returns a shared no-op when `deephaven.diagnostics.connectTiming` is off, so + * disabled diagnostics cost one config read and nothing else — no timestamps, no + * closures, no log formatting. + * + * @example + * const done = createConnectTimer(); + * await getUserInfo(); + * done('getUserInfo'); + * @returns A function that logs `timing: