From 56f5a750c88d218dff6e09200a1d7593cf85280c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 06:28:33 -0700 Subject: [PATCH] fix(remote): stabilize workspace session restore --- .../cli/src/peer_host/commands/session.rs | 7 + src/apps/desktop/src/api/agentic_api.rs | 34 ++- .../desktop/src/api/external_sources_api.rs | 12 +- .../src/remote_ssh/manager.rs | 84 +++++++ .../ssh-remote/SSHRemoteProvider.test.tsx | 50 ++++ .../features/ssh-remote/SSHRemoteProvider.tsx | 230 +++++++++++------- .../flow_chat/components/ModelSelector.tsx | 7 +- .../flow-chat-manager/MessageModule.test.ts | 18 +- .../flow-chat-manager/MessageModule.ts | 8 + .../api/service-api/AgentAPI.ts | 4 + 10 files changed, 351 insertions(+), 103 deletions(-) diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index 2a8201ca4..a5bcbf475 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -460,6 +460,13 @@ pub(crate) async fn update_session_model( let request = request_value(args); let session_id = validated_session_id(request)?; let model_name = get_string(request, "modelName")?; + if request + .get("workspacePath") + .and_then(Value::as_str) + .is_some_and(|path| !path.trim().is_empty()) + { + ensure_coordinator_session(state, args).await?; + } state .agent_runtime .update_session_model(AgentSessionModelUpdateRequest { diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index def58f79a..dfafdf44b 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -220,6 +220,14 @@ fn is_idempotent_review_create(request: &CreateSessionRequest) -> bool { pub struct UpdateSessionModelRequest { pub session_id: String, pub model_name: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_internal: bool, } #[derive(Debug, Deserialize)] @@ -1644,10 +1652,34 @@ pub async fn update_session_model( runtime: State<'_, DesktopRuntimeContext>, request: UpdateSessionModelRequest, ) -> Result<(), String> { + let session_id = request.session_id.trim().to_string(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + if let Some(workspace_path) = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + runtime + .session_application() + .ensure_session_loaded( + desktop_session_scope( + workspace_path.to_string(), + request.remote_connection_id, + request.remote_ssh_host, + ), + &session_id, + request.include_internal, + ) + .await + .map_err(|error| format!("Failed to restore session before model update: {error}"))?; + } runtime .agent_runtime() .update_session_model(AgentSessionModelUpdateRequest { - session_id: request.session_id, + session_id, model_id: request.model_name, }) .await diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 7e1b45dd7..d19e8b031 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -211,12 +211,6 @@ pub(super) async fn require_local_workspace( let Some(workspace_path) = workspace_path else { return Ok(None); }; - let path = Path::new(workspace_path); - if !path.is_absolute() { - return Err(ExternalSourceOperationError::invalid_request( - "External AI application sources require an absolute workspace path", - )); - } if is_remote_path(workspace_path).await { return Err(ExternalSourceOperationError::new( ExternalSourceOperationErrorCode::HostUnavailable, @@ -224,6 +218,12 @@ pub(super) async fn require_local_workspace( true, )); } + let path = Path::new(workspace_path); + if !path.is_absolute() { + return Err(ExternalSourceOperationError::invalid_request( + "External AI application sources require an absolute workspace path", + )); + } Ok(Some(path)) } diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index e62d25e81..6376f3645 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -2447,6 +2447,32 @@ impl SSHConnectionManager { config: SSHConnectionConfig, timeout_secs: u64, ) -> anyhow::Result { + // Explicit connects and transparent reconnects share the same + // connection id. Serialize them so concurrent workspace restoration + // cannot publish several sessions under one id and disconnect each + // previous winner as the next attempt completes. + let connection_lock = self.reconnect_lock_for(&config.id).await; + let _connection_guard = connection_lock.lock().await; + + // Startup may restore several workspaces that share one saved SSH + // profile. Once the first caller has restored a matching live + // connection, later callers only need to open their workspace paths. + if let Some(existing_result) = { + let guard = self.connections.read().await; + guard.get(&config.id).and_then(|connection| { + (connection.alive.load(Ordering::SeqCst) + && config.connection_params_equal(&connection.config)) + .then(|| SSHConnectionResult { + success: true, + connection_id: Some(config.id.clone()), + error: None, + server_info: connection.server_info.clone(), + }) + }) + } { + return Ok(existing_result); + } + let established = self .establish_session_with_retries(&config, timeout_secs) .await?; @@ -5352,6 +5378,64 @@ mod tests { )) } + #[tokio::test] + async fn explicit_connect_reuses_a_matching_live_connection() { + let manager = SSHConnectionManager::new(test_data_dir("connect-idempotent")); + let config = SSHConnectionConfig { + id: "ssh-root@example.com".to_string(), + name: "example".to_string(), + host: "example.com".to_string(), + port: 22, + username: "root".to_string(), + auth: SSHAuthMethod::PrivateKey { + key_path: "/tmp/id_rsa".to_string(), + passphrase: None, + certificate_path: None, + }, + default_workspace: Some("/srv/project".to_string()), + proxy_jump: None, + container: None, + options: Default::default(), + }; + let alive = Arc::new(AtomicBool::new(true)); + let server_info = ServerInfo { + os_type: "Linux".to_string(), + hostname: "example".to_string(), + home_dir: "/root".to_string(), + }; + manager.connections.write().await.insert( + config.id.clone(), + ActiveConnection { + handle: None, + jump_handles: Vec::new(), + config: config.clone(), + effective_config: config.clone(), + server_info: Some(server_info.clone()), + sftp_session: Arc::new(tokio::sync::RwLock::new(None)), + server_key: None, + alive: alive.clone(), + }, + ); + + let result = manager + .connect_with_timeout(config.clone(), 1) + .await + .unwrap(); + + assert!(result.success); + assert_eq!(result.connection_id.as_deref(), Some(config.id.as_str())); + assert_eq!( + result + .server_info + .as_ref() + .map(|info| info.hostname.as_str()), + Some(server_info.hostname.as_str()) + ); + let guard = manager.connections.read().await; + let active = guard.get(&config.id).unwrap(); + assert!(Arc::ptr_eq(&active.alive, &alive)); + } + #[test] fn parses_proxy_jump_alias_user_port_and_ipv6() { assert_eq!( diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx index 0fc13543d..3c2f376f8 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.test.tsx @@ -209,6 +209,56 @@ describe('SSHRemoteProvider startup restore', () => { ); }); + it('reconnects one SSH profile once when several workspaces share it', async () => { + const firstWorkspace = { + id: 'ws-shared-1', + name: 'first', + rootPath: '/srv/first', + workspaceType: WorkspaceType.SingleProject, + workspaceKind: WorkspaceKind.Remote, + languages: [] as string[], + openedAt: new Date().toISOString(), + lastAccessed: new Date().toISOString(), + tags: [] as string[], + connectionId: 'conn-shared', + connectionName: 'shared-box', + sshHost: 'shared.example.com', + }; + const secondWorkspace = { + ...firstWorkspace, + id: 'ws-shared-2', + name: 'second', + rootPath: '/srv/second', + }; + workspaceManagerMock.getState.mockReturnValue({ + loading: false, + openedWorkspaces: new Map([ + [firstWorkspace.id, firstWorkspace], + [secondWorkspace.id, secondWorkspace], + ]), + activeWorkspaceId: firstWorkspace.id, + }); + sshApiMock.listSavedConnections.mockResolvedValue([{ + id: 'conn-shared', + name: 'shared-box', + host: 'shared.example.com', + port: 22, + username: 'root', + authType: { type: 'PrivateKey', keyPath: '/tmp/id_rsa' }, + }]); + sshApiMock.connect.mockResolvedValue({ + success: true, + connectionId: 'conn-shared', + }); + + await renderProvider(); + + expect(sshApiMock.connect).toHaveBeenCalledTimes(1); + expect(sshApiMock.openWorkspace).toHaveBeenCalledTimes(2); + expect(sshApiMock.openWorkspace).toHaveBeenCalledWith('conn-shared', '/srv/first'); + expect(sshApiMock.openWorkspace).toHaveBeenCalledWith('conn-shared', '/srv/second'); + }); + it('keeps a disconnected remote workspace after the 180s reconnect budget elapses', async () => { vi.useFakeTimers(); diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 5db376b67..06f2e9738 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -146,6 +146,11 @@ export const SSHRemoteProvider: React.FC = ({ children } const remoteWorkspaceRef = useRef(null); const startHeartbeatRef = useRef<(connId: string) => void>(() => {}); const checkRemoteWorkspaceRef = useRef<() => Promise>(async () => {}); + const checkRemoteWorkspaceInFlightRef = useRef(false); + const reconnectByConnectionRef = useRef(new Map< + string, + Promise + >()); workspaceStatusesRef.current = workspaceStatuses; remoteWorkspaceRef.current = remoteWorkspace; @@ -273,110 +278,142 @@ export const SSHRemoteProvider: React.FC = ({ children } workspace: RemoteWorkspace, timeoutMs: number = REMOTE_WORKSPACE_RECONNECT_TIMEOUT_MS ): Promise => { - log.info('tryReconnectWithRetry: starting', { workspace, timeoutMs }); + const connectionKey = workspace.connectionId.trim(); + let reconnect = reconnectByConnectionRef.current.get(connectionKey); + + if (!reconnect) { + log.info('tryReconnectWithRetry: starting connection restore', { + connectionId: connectionKey, + timeoutMs, + }); + reconnect = (async () => { + const savedConnections = await sshApi.listSavedConnections(); + const savedConn = savedConnections.find(c => c.id === connectionKey); + + if (!savedConn) { + log.warn('No saved connection found for workspace', { connectionId: connectionKey }); + return false; + } - const savedConnections = await sshApi.listSavedConnections(); - const savedConn = savedConnections.find(c => c.id === workspace.connectionId); + // Determine auth method from tagged enum (password uses empty string; backend fills from vault) + let authMethod: SSHConnectionConfig['auth']; + if (savedConn.authType.type === 'PrivateKey') { + authMethod = { + type: 'PrivateKey', + keyPath: savedConn.authType.keyPath, + certificatePath: savedConn.authType.certificatePath, + }; + } else if (savedConn.authType.type === 'Agent') { + authMethod = { + type: 'Agent', + keyFingerprint: savedConn.authType.keyFingerprint, + fallbackKeyPath: savedConn.authType.fallbackKeyPath, + }; + } else if (savedConn.authType.type === 'KeyboardInteractive') { + return false; + } else { + // Caller must only invoke password reconnect when vault has a password (see checkRemoteWorkspace). + authMethod = { type: 'Password', password: '' }; + } - if (!savedConn) { - log.warn('No saved connection found for workspace', { connectionId: workspace.connectionId }); - return false; - } + const reconnectConfig: SSHConnectionConfig = { + id: savedConn.id, + name: savedConn.name, + host: savedConn.host, + port: savedConn.port, + username: savedConn.username, + auth: authMethod, + defaultWorkspace: savedConn.defaultWorkspace, + proxyJump: savedConn.proxyJump, + container: savedConn.container, + options: savedConn.options, + }; - // Determine auth method from tagged enum (password uses empty string; backend fills from vault) - let authMethod: SSHConnectionConfig['auth'] | null = null; - if (savedConn.authType.type === 'PrivateKey') { - authMethod = { - type: 'PrivateKey', - keyPath: savedConn.authType.keyPath, - certificatePath: savedConn.authType.certificatePath, - }; - } else if (savedConn.authType.type === 'Agent') { - authMethod = { - type: 'Agent', - keyFingerprint: savedConn.authType.keyFingerprint, - fallbackKeyPath: savedConn.authType.fallbackKeyPath, - }; - } else if (savedConn.authType.type === 'KeyboardInteractive') { - return false; - } else { - // Caller must only invoke password reconnect when vault has a password (see checkRemoteWorkspace). - authMethod = { type: 'Password', password: '' }; - } + const result = await reconnectUntilDeadline({ + totalTimeoutMs: timeoutMs, + attempt: async (attemptTimeoutMs, attempt) => { + if (isPeerDeviceModeActive()) { + // Abort controller-side reconnects: connecting now would open an SSH + // session on the peer with controller-local credentials. + throw new Error('Peer device mode activated'); + } + log.info(`Attempting to reconnect (attempt ${attempt})`, { + connectionId: connectionKey, + host: reconnectConfig.host, + attemptTimeoutMs, + }); - const reconnectConfig: SSHConnectionConfig = { - id: savedConn.id, - name: savedConn.name, - host: savedConn.host, - port: savedConn.port, - username: savedConn.username, - auth: authMethod, - defaultWorkspace: savedConn.defaultWorkspace, - proxyJump: savedConn.proxyJump, - container: savedConn.container, - options: savedConn.options, - }; + const connectWithTimeout = async (): Promise<{ connectionId: string }> => { + const connectionResult = await sshApi.connect(reconnectConfig); + if (!connectionResult.success || !connectionResult.connectionId) { + throw new Error(connectionResult.error || 'Connection failed'); + } + return { connectionId: connectionResult.connectionId }; + }; - return reconnectUntilDeadline({ - totalTimeoutMs: timeoutMs, - attempt: async (attemptTimeoutMs, attempt) => { - if (isPeerDeviceModeActive()) { - // Abort controller-side reconnects: connecting now would open an SSH - // session on the peer with controller-local credentials. - throw new Error('Peer device mode activated'); - } - log.info(`Attempting to reconnect (attempt ${attempt})`, { - connectionId: workspace.connectionId, - host: reconnectConfig.host, - attemptTimeoutMs, + let timeoutId: number | undefined; + try { + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout( + () => reject(new Error('Connection timeout')), + attemptTimeoutMs + ); + }); + return await Promise.race([connectWithTimeout(), timeoutPromise]); + } catch (err) { + log.warn(`Reconnect attempt ${attempt} failed`, { + connectionId: connectionKey, + error: err, + }); + throw err; + } finally { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + } + }, }); - const connectWithTimeout = async (): Promise<{ connectionId: string }> => { - const result = await sshApi.connect(reconnectConfig); - if (!result.success || !result.connectionId) { - throw new Error(result.error || 'Connection failed'); - } - return { connectionId: result.connectionId }; + if (result === false) { + return false; + } + return { + connectionId: result.connectionId, + connectionName: savedConn.name, + sshHost: reconnectConfig.host?.trim() || workspace.sshHost?.trim() || undefined, }; + })(); + reconnectByConnectionRef.current.set(connectionKey, reconnect); + const clearReconnect = () => { + if (reconnectByConnectionRef.current.get(connectionKey) === reconnect) { + reconnectByConnectionRef.current.delete(connectionKey); + } + }; + void reconnect.then(clearReconnect, clearReconnect); + } else { + log.debug('Joining in-flight remote connection restore', { connectionId: connectionKey }); + } - let timeoutId: number | undefined; - try { - const timeoutPromise = new Promise((_, reject) => { - timeoutId = window.setTimeout( - () => reject(new Error('Connection timeout')), - attemptTimeoutMs - ); - }); - - const result = await Promise.race([connectWithTimeout(), timeoutPromise]); - - // Successfully connected — open the workspace in SSH state manager - await sshApi.openWorkspace(result.connectionId, workspace.remotePath); - const reconnectedWorkspace: RemoteWorkspace = { - connectionId: result.connectionId, - connectionName: savedConn.name, - remotePath: workspace.remotePath, - sshHost: reconnectConfig.host?.trim() || workspace.sshHost?.trim() || undefined, - }; + const result = await reconnect; + if (result === false) { + return false; + } - log.info('Successfully reconnected to remote workspace', { - originalConnectionId: workspace.connectionId, - newConnectionId: result.connectionId, - }); - return { workspace: reconnectedWorkspace, connectionId: result.connectionId }; - } catch (err) { - log.warn(`Reconnect attempt ${attempt} failed`, { - connectionId: workspace.connectionId, - error: err, - }); - throw err; - } finally { - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } - } - }, + // A connection can own several opened workspace roots. Connect once, then + // register every caller's path against the shared live transport. + await sshApi.openWorkspace(result.connectionId, workspace.remotePath); + const reconnectedWorkspace: RemoteWorkspace = { + connectionId: result.connectionId, + connectionName: result.connectionName, + remotePath: workspace.remotePath, + sshHost: result.sshHost, + }; + log.info('Successfully reconnected to remote workspace', { + originalConnectionId: workspace.connectionId, + newConnectionId: result.connectionId, + remotePath: workspace.remotePath, }); + return { workspace: reconnectedWorkspace, connectionId: result.connectionId }; }, []); const statusRef = useRef(status); @@ -426,6 +463,11 @@ export const SSHRemoteProvider: React.FC = ({ children } log.info('checkRemoteWorkspace: skipped while peer device mode is active'); return; } + if (checkRemoteWorkspaceInFlightRef.current) { + log.debug('checkRemoteWorkspace: skipped because startup restore is already in progress'); + return; + } + checkRemoteWorkspaceInFlightRef.current = true; try { // ── Collect all remote workspaces to reconnect ────────────────────── const wmState0 = workspaceManager.getState(); @@ -627,6 +669,8 @@ export const SSHRemoteProvider: React.FC = ({ children } } } catch (e) { log.error('checkRemoteWorkspace failed', e); + } finally { + checkRemoteWorkspaceInFlightRef.current = false; } }, [ reportRemoteWorkspaceReconnectFailure, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 35c8c6b05..3dbf6ad74 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -24,6 +24,7 @@ import { FlowChatStore } from '../store/FlowChatStore'; import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule'; import { acpClientIdFromAgentType } from '../utils/acpSession'; import { buildAcpFastModeValue, resolveAcpFastModeState } from '../utils/acpSessionConfig'; +import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { buildContextUsageTooltip, type ContextUsageSource, @@ -540,10 +541,14 @@ export const ModelSelector: React.FC = ({ const maxContextTokens = await getModelMaxTokens(modelId, currentMode); store.updateSessionMaxContextTokens(sessionId, maxContextTokens); const session = store.getState().sessions.get(sessionId); - if (!session?.isTransient) { + if (session && !session.isTransient) { await agentAPI.updateSessionModel({ sessionId, modelName: modelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', }); } }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 24f0f370d..1e0a3067a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -537,7 +537,13 @@ describe('MessageModule model synchronization', () => { it('keeps an explicit auto selector when synchronizing before send', async () => { const session = { sessionId: 'session-auto', - config: { modelName: 'auto' }, + config: { + modelName: 'auto', + workspacePath: '/remote/repo', + }, + remoteConnectionId: 'ssh-1', + remoteSshHost: 'example.test', + sessionKind: 'subagent', maxContextTokens: 64000, }; const updateSessionModelName = vi.fn(); @@ -557,13 +563,17 @@ describe('MessageModule model synchronization', () => { expect(mockUpdateSessionModel).toHaveBeenCalledWith({ sessionId: 'session-auto', modelName: 'auto', + workspacePath: '/remote/repo', + remoteConnectionId: 'ssh-1', + remoteSshHost: 'example.test', + includeInternal: true, }); }); it('migrates a legacy session without a model to the current mode default', async () => { const session = { sessionId: 'legacy-session', - config: {}, + config: { workspacePath: '/local/repo' }, maxContextTokens: 32000, }; const updateSessionModelName = vi.fn(); @@ -583,6 +593,10 @@ describe('MessageModule model synchronization', () => { expect(mockUpdateSessionModel).toHaveBeenCalledWith({ sessionId: 'legacy-session', modelName: 'model-b', + workspacePath: '/local/repo', + remoteConnectionId: undefined, + remoteSshHost: undefined, + includeInternal: false, }); }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 99e8fc1ce..5dcc574b2 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -129,6 +129,10 @@ export async function syncSessionModelSelection( await agentAPI.updateSessionModel({ sessionId, modelName: sessionModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', }); return; } @@ -154,6 +158,10 @@ export async function syncSessionModelSelection( await agentAPI.updateSessionModel({ sessionId, modelName: desiredModelId, + workspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', }); log.info('Session model synchronized before send', { diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 5e729a676..2b5598238 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -260,6 +260,10 @@ export interface EnsureAssistantBootstrapResponse { export interface UpdateSessionModelRequest { sessionId: string; modelName: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + includeInternal?: boolean; } export interface UpdateSessionTitleRequest {