Path: blob/main/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Codicon } from '../../../../base/common/codicons.js';6import { Emitter, Event } from '../../../../base/common/event.js';7import { MarkdownString } from '../../../../base/common/htmlContent.js';8import { DisposableStore } from '../../../../base/common/lifecycle.js';9import { Schemas } from '../../../../base/common/network.js';10import { basename, dirname } from '../../../../base/common/resources.js';11import { IObservable, observableValue } from '../../../../base/common/observable.js';12import { isWeb } from '../../../../base/common/platform.js';13import { ThemeIcon } from '../../../../base/common/themables.js';14import { URI } from '../../../../base/common/uri.js';15import { localize } from '../../../../nls.js';16import { agentHostUri } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js';17import { AGENT_HOST_SCHEME, agentHostAuthority, toAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js';18import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../platform/agentHost/common/agentService.js';19import type { ISessionGitState } from '../../../../platform/agentHost/common/state/sessionState.js';20import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';21import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';22import { ILabelService } from '../../../../platform/label/common/label.js';23import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';24import { INotificationService } from '../../../../platform/notification/common/notification.js';25import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';26import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js';27import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js';28import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';29import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js';30import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js';31import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../common/agentHostSessionWorkspace.js';32import { ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js';33import { remoteAgentHostSessionTypeId } from '../common/remoteAgentHostSessionType.js';3435/** Storage key prefix for cached session summaries, per remote address. */36const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.';3738/** Maximum number of cached session summaries persisted per host. */39const CACHED_SESSIONS_MAX_PER_HOST = 100;4041/**42* Serialized shape of an {@link IAgentSessionMetadata} suitable for43* persisting via {@link IStorageService}. URIs are stored as strings44* and diffs are intentionally omitted (they are re-populated when the45* connection refreshes sessions).46*/47interface ISerializedSessionMetadata {48readonly session: string;49readonly startTime: number;50readonly modifiedTime: number;51readonly summary?: string;52readonly model?: IAgentSessionMetadata['model'];53readonly workingDirectory?: string;54readonly isRead?: boolean;55readonly isArchived?: boolean;56/** @deprecated Legacy name for `isArchived`. */57readonly isDone?: boolean;58readonly project?: { readonly uri: string; readonly displayName: string };59}6061function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata {62return {63session: meta.session.toString(),64startTime: meta.startTime,65modifiedTime: meta.modifiedTime,66summary: meta.summary,67model: meta.model,68workingDirectory: meta.workingDirectory?.toString(),69isRead: meta.isRead,70isArchived: meta.isArchived,71project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined,72};73}7475function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined {76try {77return {78session: URI.parse(raw.session),79startTime: raw.startTime,80modifiedTime: raw.modifiedTime,81summary: raw.summary,82model: raw.model,83workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined,84isRead: raw.isRead,85isArchived: raw.isArchived ?? raw.isDone,86project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined,87};88} catch {89return undefined;90}91}9293function toLocalProjectUri(uri: URI, connectionAuthority: string): URI {94return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri;95}9697export interface IRemoteAgentHostSessionsProviderConfig {98readonly address: string;99readonly name: string;100/** Optional hook to establish a connection on demand (e.g. tunnel relay). */101readonly connectOnDemand?: () => Promise<void>;102/** Optional hook to tear down the active connection on demand (e.g. tunnel relay). */103readonly disconnectOnDemand?: () => Promise<void>;104}105106/**107* Sessions provider for a remote agent host connection. A thin subclass of108* {@link BaseAgentHostSessionsProvider} that adds the connection-lifecycle109* surface (`setConnection`/`clearConnection`), sticky authentication-pending110* tracking, the well-known session-type mapping, and a remote folder picker.111*112* **URI/ID scheme:**113* - **rawId** - unique session identifier (e.g. `abc123`), used as the cache key.114* - **resource** - `{resourceScheme}:///{rawId}`. The scheme is the unique115* per-connection id and routes the chat service to the correct116* {@link AgentHostSessionHandler}.117* - **sessionType** - the logical session type (e.g. `copilotcli` for copilot118* agents, or the per-connection id for other agents). Distinct from the119* resource scheme.120* - **sessionId** - `{providerId}:{resource}` - the provider-scoped ID used by121* {@link ISessionsProvider} methods.122* - Protocol operations (e.g. `disposeSession`) use the canonical agent123* session URI (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}.124*/125export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvider {126127readonly id: string;128readonly label: string;129readonly icon: ThemeIcon = Codicon.remote;130readonly remoteAddress: string;131readonly browseActions: readonly ISessionWorkspaceBrowseAction[];132133private _outputChannelId: string | undefined;134get outputChannelId(): string | undefined { return this._outputChannelId; }135136private readonly _connectionStatus = observableValue<RemoteAgentHostConnectionStatus>('connectionStatus', RemoteAgentHostConnectionStatus.Disconnected);137readonly connectionStatus: IObservable<RemoteAgentHostConnectionStatus> = this._connectionStatus;138139/**140* `true` while we are still resolving and pushing tokens for the host's141* `protectedResources`. Defaults to `true` so that sessions surface as142* loading until the first authentication pass settles.143*/144private readonly _authenticationPending = observableValue('authenticationPending', true);145private _authenticationSettled = false;146147private readonly _onDidDisconnect = this._register(new Emitter<void>());148protected override get onConnectionLost(): Event<void> { return this._onDidDisconnect.event; }149150/**151* Overridable seam so tests can exercise both the web and non-web152* branches of the label/description gating without depending on the153* ambient {@link isWeb} constant (the browser test runner always154* reports `isWeb === true`).155*/156protected get isWebPlatform(): boolean { return isWeb; }157158private _connection: IAgentConnection | undefined;159private _defaultDirectory: string | undefined;160private readonly _connectionListeners = this._register(new DisposableStore());161private readonly _connectionAuthority: string;162private readonly _connectOnDemand: (() => Promise<void>) | undefined;163private readonly _disconnectOnDemand: (() => Promise<void>) | undefined;164/** Storage key used for persisting {@link _sessionCache} snapshots. */165private readonly _storageKey: string;166/**167* Set when {@link _sessionCache} has changed since the last persist.168* The actual write happens on the next `onWillSaveState` signal from169* {@link IStorageService} so that bursts of notifications do not170* repeatedly re-serialize the whole cache.171*/172private _cacheDirty = false;173/**174* Snapshot of the source metadata for each adapter in {@link _sessionCache},175* keyed by raw session ID. Captured in {@link createAdapter} and re-used by176* {@link _persistCache} to serialize sessions without having to reconstruct177* every `IAgentSessionMetadata` field from observables.178*/179private readonly _metaByRawId = new Map<string, IAgentSessionMetadata>();180/**181* When `true`, the provider has been marked unreachable and sessions are182* hidden from {@link getSessions}, even though {@link _sessionCache} and183* persistent storage are retained. Cleared when a new connection is wired184* up in {@link setConnection}, at which point the cached entries are185* re-announced so the UI can repopulate.186*/187private _unpublished = false;188189constructor(190config: IRemoteAgentHostSessionsProviderConfig,191@IFileDialogService private readonly _fileDialogService: IFileDialogService,192@INotificationService private readonly _notificationService: INotificationService,193@IStorageService private readonly _storageService: IStorageService,194@IChatSessionsService chatSessionsService: IChatSessionsService,195@IChatService chatService: IChatService,196@IChatWidgetService chatWidgetService: IChatWidgetService,197@ILanguageModelsService languageModelsService: ILanguageModelsService,198@IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService,199@ILabelService private readonly _labelService: ILabelService,200@IConfigurationService private readonly _configurationService: IConfigurationService,201) {202super(chatSessionsService, chatService, chatWidgetService, languageModelsService);203204this._connectionAuthority = agentHostAuthority(config.address);205this._connectOnDemand = config.connectOnDemand;206this._disconnectOnDemand = config.disconnectOnDemand;207const displayName = config.name || config.address;208209this.id = `agenthost-${this._connectionAuthority}`;210this.label = displayName;211this.remoteAddress = config.address;212this._storageKey = `${CACHED_SESSIONS_STORAGE_PREFIX}${this._connectionAuthority}`;213214this.browseActions = [{215label: localize('folders', "Folders"),216description: displayName,217group: SESSION_WORKSPACE_GROUP_REMOTE,218icon: Codicon.remote,219providerId: this.id,220run: () => this._browseForFolder(),221}];222223this._loadCachedSessions();224225this._register(this._onDidChangeSessions.event(e => {226if (this._unpublished) {227return;228}229if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) {230this._cacheDirty = true;231}232for (const removed of e.removed) {233const rawId = this._rawIdFromChatId(removed.sessionId);234if (rawId) {235this._metaByRawId.delete(rawId);236}237}238}));239240this._register(this._storageService.onWillSaveState(() => {241if (this._cacheDirty) {242this._persistCache();243this._cacheDirty = false;244}245}));246}247248// -- BaseAgentHostSessionsProvider hooks ---------------------------------249250protected get connection(): IAgentConnection | undefined { return this._connection; }251252protected get authenticationPending(): IObservable<boolean> { return this._authenticationPending; }253254protected override createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter {255this._metaByRawId.set(AgentSession.id(meta.session), meta);256return super.createAdapter(meta);257}258259protected _adapterOptions() {260const web = this.isWebPlatform;261return {262description: web ? undefined : new MarkdownString().appendText(this.label),263buildWorkspace: (project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, gitState: ISessionGitState | undefined) => {264const uriForDescription = project?.uri ?? workingDirectory;265const description = uriForDescription ? this._labelService.getUriLabel(dirname(uriForDescription), { relative: false }) : undefined;266const branchProtectionPatterns = readBranchProtectionPatterns(this._configurationService, workingDirectory ?? project?.uri);267return RemoteAgentHostSessionsProvider.buildWorkspace(project, workingDirectory, web ? undefined : this.label, gitState, description, branchProtectionPatterns);268},269};270}271272protected resourceSchemeForProvider(provider: string): string {273return remoteAgentHostSessionTypeId(this._connectionAuthority, provider);274}275276override getSessions(): ISession[] {277return this._unpublished ? [] : super.getSessions();278}279280protected override mapWorkingDirectoryUri(uri: URI): URI {281return toAgentHostUri(uri, this._connectionAuthority);282}283284protected override mapProjectUri(uri: URI): URI {285return toLocalProjectUri(uri, this._connectionAuthority);286}287288protected override _diffUriMapper(): (uri: URI) => URI {289return uri => toAgentHostUri(uri, this._connectionAuthority);290}291292protected override _validateBeforeCreate(_sessionType: ISessionType): void {293if (!this._connection) {294throw new Error(localize('notConnectedSession', "Cannot create session: not connected to remote agent host '{0}'.", this.label));295}296}297298protected override _noAgentsErrorMessage(): string {299return localize('noAgents', "Remote agent host '{0}' has not advertised any agents yet.", this.label);300}301302protected override _notConnectedSendErrorMessage(): string {303return localize('notConnectedSend', "Cannot send request: not connected to remote agent host '{0}'.", this.label);304}305306// -- Connection lifecycle ------------------------------------------------307308/**309* Establish (or re-establish) the connection for this host on demand.310* Tunnel-backed providers use their relay hook; other providers fall311* back to the generic remote agent host reconnect path.312*/313async connect(): Promise<void> {314if (this._connectOnDemand) {315await this._connectOnDemand();316return;317}318this._remoteAgentHostService.reconnect(this.remoteAddress);319}320321/**322* Tear down the active connection for this host. Tunnel-backed providers323* use their relay hook; other providers fall back to the generic remote324* agent host disconnect path. Cached sessions are hidden from the UI so325* the sessions list reflects the disconnected state; the persisted cache326* is retained so sessions can be restored on reconnect.327*/328async disconnect(): Promise<void> {329this.unpublishCachedSessions();330if (this._disconnectOnDemand) {331await this._disconnectOnDemand();332return;333}334await this._remoteAgentHostService.removeRemoteAgentHost(this.remoteAddress);335}336337/** Update the connection status for this provider. */338setConnectionStatus(status: RemoteAgentHostConnectionStatus): void {339this._connectionStatus.set(status, undefined);340}341342/** Set the output channel ID for this provider's IPC log. */343setOutputChannelId(id: string): void {344this._outputChannelId = id;345}346347setAuthenticationPending(pending: boolean): void {348// Sticky: once the first authentication pass settles, never surface349// pending again. Subsequent re-auths happen silently in the background.350if (this._authenticationSettled) {351return;352}353if (!pending) {354this._authenticationSettled = true;355}356this._authenticationPending.set(pending, undefined);357}358359/**360* Wire a live connection to this provider, enabling session operations and folder browsing.361*/362setConnection(connection: IAgentConnection, defaultDirectory?: string): void {363if (this._connection === connection && this._defaultDirectory === defaultDirectory) {364return;365}366367this._connectionListeners.clear();368this._sessionStateSubscriptions.clearAndDisposeAll();369this._connection = connection;370this._defaultDirectory = defaultDirectory;371this._unpublished = false;372373// Dynamically discover session types from the host's advertised agents.374const rootStateValue = connection.rootState.value;375if (rootStateValue && !(rootStateValue instanceof Error)) {376this._syncSessionTypesFromRootState(rootStateValue);377this._syncRootConfigFromRootState(rootStateValue);378}379this._connectionListeners.add(connection.rootState.onDidChange(rootState => {380this._syncSessionTypesFromRootState(rootState);381this._syncRootConfigFromRootState(rootState);382}));383384this._attachConnectionListeners(connection, this._connectionListeners);385386// Always refresh sessions when a connection is (re)established387this._cacheInitialized = true;388this._refreshSessions();389}390391/**392* Clear the connection, e.g. when the remote host disconnects.393* Retains the provider registration so it remains visible in the UI,394* and **preserves** the cached session list so previously loaded395* sessions stay visible while we're offline. Callers that know the396* host is unreachable should follow up with {@link unpublishCachedSessions}.397*/398clearConnection(): void {399this._connectionListeners.clear();400this._sessionStateSubscriptions.clearAndDisposeAll();401this._onDidDisconnect.fire();402this._connection = undefined;403this._defaultDirectory = undefined;404if (this._currentNewSession) {405this._clearNewSessionConfig(this._currentNewSession.sessionId);406this._currentNewSession = undefined;407}408this._currentNewSessionStatus = undefined;409this._currentNewSessionModelId = undefined;410this._currentNewSessionLoading = undefined;411this._selectedModelId = undefined;412413if (this._sessionTypes.length > 0) {414this._sessionTypes = [];415this._onDidChangeSessionTypes.fire();416}417418// Drop only the transient pending/draft session; keep the persisted419// cache so the workspace picker keeps showing offline sessions.420if (this._pendingSession) {421const pending = this._pendingSession;422this._pendingSession = undefined;423this._onDidChangeSessions.fire({ added: [], removed: [pending], changed: [] });424}425426// Reset the in-memory cache-initialized flag so a fresh connection427// triggers a full list refresh (which will reconcile against the428// persisted entries we keep on disk).429this._cacheInitialized = false;430}431432/**433* Hide cached sessions from the UI without discarding them. Called by the434* host-tracking contributions when they determine the remote host is435* unreachable (tunnel offline or SSH reconnect failed). The in-memory436* cache and persisted storage are left intact so the sessions can be437* restored if the host comes back online in this session, or on the next438* launch. The next {@link setConnection} call re-announces the cached439* entries.440*/441unpublishCachedSessions(): void {442if (this._unpublished) {443return;444}445this._unpublished = true;446const removed: ISession[] = Array.from(this._sessionCache.values());447if (removed.length > 0) {448this._onDidChangeSessions.fire({ added: [], removed, changed: [] });449}450}451452/** Load persisted session summaries into {@link _sessionCache}. */453private _loadCachedSessions(): void {454const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION);455if (!Array.isArray(parsed)) {456return;457}458for (const entry of parsed as readonly ISerializedSessionMetadata[]) {459const meta = deserializeMetadata(entry);460if (!meta) {461continue;462}463const rawId = AgentSession.id(meta.session);464if (this._sessionCache.has(rawId)) {465continue;466}467const cached = this.createAdapter(meta);468this._sessionCache.set(rawId, cached);469}470}471472/**473* Persist the current {@link _sessionCache} to storage, capping at474* {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries.475* Mutable fields are read from each adapter's observables and overlaid on476* top of the original metadata snapshot captured in {@link _metaByRawId}.477*/478private _persistCache(): void {479const entries: ISerializedSessionMetadata[] = [];480for (const [rawId, adapter] of this._sessionCache) {481const base = this._metaByRawId.get(rawId);482if (!base) {483continue;484}485entries.push(serializeMetadata({486...base,487summary: adapter.title.get() || base.summary,488modifiedTime: adapter.updatedAt.get().getTime(),489model: adapter.modelSelection ?? base.model,490isRead: adapter.isRead.get(),491isArchived: adapter.isArchived.get(),492}));493}494if (entries.length === 0) {495this._storageService.remove(this._storageKey, StorageScope.APPLICATION);496return;497}498entries.sort((a, b) => b.modifiedTime - a.modifiedTime);499const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST);500this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER);501}502503// -- Session-type sync ---------------------------------------------------504505protected _formatSessionTypeLabel(agentLabel: string): string {506return `${agentLabel} [${this.label}]`;507}508509// -- Workspaces ----------------------------------------------------------510511static buildWorkspace(project: IAgentSessionMetadata['project'], workingDirectory: URI | undefined, providerLabel: string | undefined, gitState: ISessionGitState | undefined, description?: string, branchProtectionPatterns?: readonly string[]): ISessionWorkspace | undefined {512return buildAgentHostSessionWorkspace(project, workingDirectory, { providerLabel, fallbackIcon: Codicon.remote, requiresWorkspaceTrust: false, description, branchProtectionPatterns, group: SESSION_WORKSPACE_GROUP_REMOTE }, gitState);513}514515private _buildWorkspaceFromUri(uri: URI): ISessionWorkspace {516const folderName = basename(uri) || uri.path;517return {518label: this.isWebPlatform ? folderName : `${folderName} [${this.label}]`,519description: this._labelService.getUriLabel(dirname(uri), { relative: false }),520group: SESSION_WORKSPACE_GROUP_REMOTE,521icon: Codicon.remote,522repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],523requiresWorkspaceTrust: true,524};525}526527resolveWorkspace(repositoryUri: URI): ISessionWorkspace | undefined {528if (repositoryUri.scheme !== AGENT_HOST_SCHEME) {529return undefined;530}531return this._buildWorkspaceFromUri(repositoryUri);532}533534// -- Browse --------------------------------------------------------------535536private async _browseForFolder(): Promise<ISessionWorkspace | undefined> {537// Establish connection on demand if a hook is provided (e.g. tunnel relay)538if (!this._connection && this._connectOnDemand) {539try {540await this._connectOnDemand();541} catch (err) {542this._notificationService.error(localize('connectFailed', "Failed to connect to remote agent host '{0}': {1}", this.label, err instanceof Error ? err.message : String(err)));543return undefined;544}545}546547if (!this._connection) {548this._notificationService.error(localize('notConnected', "Unable to connect to remote agent host '{0}'.", this.label));549return undefined;550}551552const defaultUri = agentHostUri(this._connectionAuthority, this._defaultDirectory ?? '/');553554try {555const selected = await this._fileDialogService.showOpenDialog({556canSelectFiles: false,557canSelectFolders: true,558canSelectMany: false,559title: localize('selectRemoteFolder', "Select Folder on {0}", this.label),560availableFileSystems: [AGENT_HOST_SCHEME],561defaultUri,562});563if (selected?.[0]) {564return this._buildWorkspaceFromUri(selected[0]);565}566} catch {567// dialog was cancelled or failed568}569return undefined;570}571}572573574