UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

534 lines (533 loc) 25.3 kB
import { createClient } from '@supabase/supabase-js'; import { captureRemote } from '../utils/capture.js'; const HEARTBEAT_INTERVAL = 15000; // Cap a single channel recreate so a hung await can't pin the re-entrancy guard // true (which would silently disable the connection watchdog). const RECREATE_TIMEOUT_MS = 30000; // Max time the channel may sit CONTINUOUSLY in 'joining' before we force a recreate. // 'joining' is normally healthy (we let realtime-js's rejoin backoff converge), but on a // HALF-OPEN socket (readyState OPEN yet dead) realtime-js parks the channel in 'joining' // forever and never reconnects the socket — the device then wedges offline silently with // no recreate firing. realtime-js's join push times out in ~10s, so a genuine join // resolves/errors well within this window; 3 health ticks of unbroken 'joining' means the // state machine has stalled and only a fresh socket (via recreate) recovers it. const JOINING_WEDGE_TIMEOUT_MS = 30000; export class RemoteChannel { constructor() { this.client = null; this.channel = null; this.heartbeatInterval = null; this.connectionCheckInterval = null; // Store subscription parameters for channel recreation this.deviceId = null; this.onToolCall = null; // Track last device status to prevent duplicate log messages this.lastDeviceStatus = 'offline'; // Track last channel state for debug logging this.lastChannelState = null; // Reconnect diagnostics + guard (see connState() / recreateChannel()) this.reconnectAttempt = 0; // recreateChannel() attempts since last success this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard) this.joiningSince = null; // ts the channel entered an unbroken 'joining' run; null when not joining this._user = null; } get user() { return this._user; } initialize(url, key) { this.client = createClient(url, key); } async setSession(session) { if (!this.client) throw new Error('Client not initialized'); console.debug('[DEBUG] RemoteChannel.setSession() called, has refresh_token:', !!session.refresh_token); const { error } = await this.client.auth.setSession({ access_token: session.access_token, refresh_token: session.refresh_token || '' }); if (error) { console.error('[DEBUG] Failed to set session:', error.message); await captureRemote('remote_channel_set_session_error', { error }); return { error }; } // Get user info const { data: { user }, error: userError } = await this.client.auth.getUser(); if (userError) { console.error('[DEBUG] Failed to get user:', userError.message); await captureRemote('remote_channel_get_user_error', { error: userError }); throw userError; } if (!user) { const noUserError = new Error('No user returned after setSession'); console.error('[DEBUG] No user returned:', noUserError.message); await captureRemote('remote_channel_get_user_empty', {}); throw noUserError; } this._user = user; console.debug('[DEBUG] Session set successfully, user:', user.email); return { error }; } async getSession() { if (!this.client) throw new Error('Client not initialized'); return await this.client.auth.getSession(); } async findDevice(deviceId) { if (!this.client) throw new Error('Client not initialized'); const { data, error } = await this.client .from('mcp_devices') .select('id, device_name') .eq('id', deviceId) .eq('user_id', this.user?.id) .maybeSingle(); if (error) { console.error('[DEBUG] Failed to find device:', error.message); await captureRemote('remote_channel_find_device_error', { error }); throw error; } return data; } async updateDevice(deviceId, updates) { if (!this.client) throw new Error('Client not initialized'); const { data, error } = await this.client .from('mcp_devices') .update(updates) .eq('id', deviceId) .select(); if (error) { console.error('[DEBUG] Failed to update device:', error.message); await captureRemote('remote_channel_update_device_error', { error }); } else { console.debug('[DEBUG] Device updated successfully'); } return { data, error }; } async createDevice(deviceData) { if (!this.client) throw new Error('Client not initialized'); const { data, error } = await this.client .from('mcp_devices') .insert(deviceData) .select() .single(); if (error) { console.error('[DEBUG] Failed to create device:', error.message); await captureRemote('remote_channel_create_device_error', { error }); throw error; } console.debug('[DEBUG] Device created successfully'); return { data, error }; } async registerDevice(capabilities, currentDeviceId, deviceName, onToolCall) { console.debug('[DEBUG] RemoteChannel.registerDevice() called, deviceId:', currentDeviceId); let existingDevice = null; if (currentDeviceId && this.user) { console.debug('[DEBUG] Finding existing device...'); existingDevice = await this.findDevice(currentDeviceId); console.debug('[DEBUG] Existing device found:', !!existingDevice); } if (existingDevice) { console.debug('[DEBUG] Updating device status to online'); await this.updateDevice(existingDevice.id, { status: 'online', last_seen: new Date().toISOString(), capabilities: {}, // TODO: Capabilities are not yet implemented; keep this empty object for schema compatibility until device capabilities are defined and stored. device_name: deviceName }); // Store parameters for channel recreation this.deviceId = existingDevice.id; this.onToolCall = onToolCall; console.debug(`⏳ Subscribing to tool call channel...`); // Create and subscribe to the channel console.debug('[DEBUG] Calling createChannel()'); // ! Ignore silently in Initialization to reconnect after await this.createChannel().catch((error) => { console.debug(`[DEBUG] Failed to create channel, will retry after socket reconnect: ${error?.message || error} — ${this.connState()}`); }); } else { console.error(` - ❌ Device not found: ${currentDeviceId}`); await captureRemote('remote_channel_register_device_error', { error: 'Device not found', deviceId: currentDeviceId }); throw new Error(`Device not found: ${currentDeviceId}`); } } /** * Create and subscribe to the channel. * This is used for both initial subscription and recreation after socket reconnects. */ createChannel() { return new Promise((resolve, reject) => { if (!this.client || !this.user?.id || !this.onToolCall) { console.debug('[DEBUG] createChannel() failed - missing prerequisites'); return reject(new Error('Client not initialized or missing subscription parameters')); } console.debug('[DEBUG] Creating channel: device_tool_call_queue'); this.channel = this.client.channel('device_tool_call_queue') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'mcp_remote_calls', filter: `user_id=eq.${this.user.id}` }, (payload) => { console.debug('[DEBUG] Realtime event received, payload:', payload?.new?.id); if (this.onToolCall) { this.onToolCall(payload); } }) .subscribe((status, err) => { // Debug: Log all subscription status events console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + (err?.message || err) + ')' : ''} — ${this.connState()}`); if (status === 'SUBSCRIBED') { const recovered = this.reconnectAttempt; this.reconnectAttempt = 0; console.log(`✅ Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`); // Update device status on successful connection if (this.deviceId) { this.setOnlineStatus(this.deviceId, 'online').catch(e => { console.error('Failed to set online status:', e.message); }); } resolve(); } else if (status === 'CHANNEL_ERROR') { // CHANNEL_ERROR is the only status carrying a real error message. console.error(`❌ Channel error: ${err?.message || 'unknown'} — ${this.connState()}`); this.setOnlineStatus(this.deviceId, 'offline'); captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { }); reject(err || new Error('Failed to initialize tool call channel subscription')); } else if (status === 'TIMED_OUT') { console.error(`⏱️ Channel subscription timed out, Reconnecting... — ${this.connState()}`); this.setOnlineStatus(this.deviceId, 'offline'); captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { }); reject(new Error('Tool call channel subscription timed out')); } else if (status === 'CLOSED') { // Settle the promise so an in-flight recreateChannel() can't await // forever (which would wedge the re-entrancy guard / watchdog), and // mark the device offline like the other degraded states. console.warn(`⚠️ Channel closed — ${this.connState()}`); this.setOnlineStatus(this.deviceId, 'offline'); reject(new Error('Tool call channel closed during subscribe')); } }); }); } /** * Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3". * readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused), * 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws. */ connState() { let socket = '?'; try { const rt = this.client?.realtime; socket = `${rt?.connectionState?.() ?? '?'}(${rt?.conn?.readyState ?? '-'})`; } catch { /* best effort */ } return `socket=${socket} ch=${this.channel?.state ?? '-'} attempt=${this.reconnectAttempt}`; } /** * Check if channel is connected, recreate if not. */ checkConnectionHealth() { if (!this.channel || !this.client || !this.user?.id || !this.onToolCall) { return; } const state = this.channel.state; // Debug: Log current channel state (only if changed) if (!this.lastChannelState || this.lastChannelState !== state) { console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`); this.lastChannelState = state; } // 'joined' = healthy. Clear the joining-overstay timer. if (state === 'joined') { this.joiningSince = null; return; } // 'joining' = transitional — normally let realtime-js's own rejoin backoff converge // instead of tearing the channel down mid-join (recreating on every non-joined state // amputates that backoff). BUT bound it: on a half-open socket realtime-js can park // the channel in 'joining' indefinitely without ever reconnecting the socket, so the // recreate below would never fire and the device wedges offline silently. If 'joining' // overstays JOINING_WEDGE_TIMEOUT_MS unbroken, force a recreate — the only path that // disconnect()s the dead socket. (connState() in the log shows the half-open socket.) if (state === 'joining') { const now = Date.now(); if (this.joiningSince === null) this.joiningSince = now; const stuckMs = now - this.joiningSince; if (stuckMs < JOINING_WEDGE_TIMEOUT_MS) return; console.debug(`[DEBUG] ⚠️ Channel stuck 'joining' ${Math.round(stuckMs / 1000)}s - forcing recreate — ${this.connState()}`); captureRemote('remote_channel_joining_wedge', { stuckMs, attempt: this.reconnectAttempt }); this.joiningSince = null; this.recreateChannel(); return; } // Unhealthy: closed, errored, leaving — recreate this.joiningSince = null; captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt }); console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`); this.recreateChannel(); } /** * Run an async op but reject if it doesn't settle within `ms`, so a hung await * can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors * closeWithTimeout() in desktop-commander-integration.ts. */ async withTimeout(op, ms, name) { let timer; try { return await Promise.race([ op(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms); }), ]); } finally { if (timer) clearTimeout(timer); } } /** * Recreate the channel by destroying old one and creating fresh instance. */ async recreateChannel() { if (!this.client || !this.user?.id || !this.onToolCall) { console.warn('Cannot recreate channel - missing parameters'); console.debug('[DEBUG] recreateChannel() aborted - missing prerequisites'); return; } // FIX: re-entrancy guard so a 10s health tick can't stack a second recreate // on top of an in-flight one. if (this.isRecreatingChannel) { console.debug('[DEBUG] recreateChannel() skipped - already in progress'); return; } this.isRecreatingChannel = true; this.reconnectAttempt++; // Create fresh channel console.log(`🔄 Recreating channel... (attempt ${this.reconnectAttempt}) — ${this.connState()}`); try { // Cap the whole recreate: a never-settling await (e.g. a subscribe that only // ever emits CLOSED) must not pin isRecreatingChannel=true and silently disable // the 10s watchdog. On timeout we reject -> catch -> finally clears the guard. await this.withTimeout(async () => { // Destroy old channel — AWAIT it so the channel registry empties before we // rebuild. (The un-awaited version raced the synchronous new-channel push, so // realtime-js never tore the socket down and a half-open one got reused.) if (this.channel) { console.debug('[DEBUG] Destroying old channel'); await this.client.removeChannel(this.channel); this.channel = null; } // FIX (core): force a brand-new WebSocket. After idle / wifi-loss the socket can // be HALF-OPEN (readyState OPEN but dead); reusing it made every join TIME_OUT // forever. disconnect() drops it so the next subscribe() dials a fresh one. try { await this.client.realtime?.disconnect?.(); } catch { /* best effort */ } console.debug('[DEBUG] Calling createChannel() for recreation'); await this.createChannel(); }, RECREATE_TIMEOUT_MS, 'recreateChannel'); } catch (err) { captureRemote('remote_channel_recreate_error', { errMsg: err?.message, attempt: this.reconnectAttempt }); console.debug(`[DEBUG] Channel recreation failed: ${err?.message} — ${this.connState()}`); } finally { this.isRecreatingChannel = false; } } async markCallExecuting(callId) { if (!this.client) throw new Error('Client not initialized'); const { error } = await this.client .from('mcp_remote_calls') .update({ status: 'executing' }) .eq('id', callId); if (error) { console.error('[DEBUG] Failed to mark call executing:', error.message); await captureRemote('remote_channel_mark_call_executing_error', { error }); } else { console.debug('[DEBUG] Call marked executing:', callId); } } async updateCallResult(callId, status, result = null, errorMessage = null) { if (!this.client) throw new Error('Client not initialized'); const updateData = { status: status, completed_at: new Date().toISOString() }; if (result !== null) updateData.result = result; if (errorMessage !== null) updateData.error_message = errorMessage; console.debug('[DEBUG] Updating call result:', updateData); const { data, error } = await this.client .from('mcp_remote_calls') .update(updateData) .eq('id', callId); if (error) { console.error('[DEBUG] Failed to update call result:', error.message); await captureRemote('remote_channel_update_call_result_error', { error }); } else { console.debug('[DEBUG] Call result updated successfully:', data); } } async updateHeartbeat(deviceId) { if (!this.client) return; try { const { error } = await this.client .from('mcp_devices') .update({ last_seen: new Date().toISOString() }) .eq('id', deviceId); if (error) { console.error('[DEBUG] Heartbeat update failed:', error.message); await captureRemote('remote_channel_heartbeat_error', { error }); } // console.log(`🔌 Heartbeat sent for device: ${deviceId}`); } catch (error) { console.error('Heartbeat failed:', error.message); await captureRemote('remote_channel_heartbeat_error', { error }); } } startHeartbeat(deviceId) { console.debug('[DEBUG] Starting heartbeat for device:', deviceId); this.connectionCheckInterval = setInterval(() => { this.checkConnectionHealth(); }, 10000); // Update last_seen every 15 seconds this.heartbeatInterval = setInterval(async () => { await this.updateHeartbeat(deviceId); }, HEARTBEAT_INTERVAL); console.debug('[DEBUG] Heartbeat intervals set - connectionCheck: 10s, heartbeat: 15s'); } stopHeartbeat() { if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; } if (this.connectionCheckInterval) { clearInterval(this.connectionCheckInterval); this.connectionCheckInterval = null; } } async setOnlineStatus(deviceId, status) { if (!this.client) return; // Only log if status changed if (this.lastDeviceStatus !== status) { console.log(`🔌 Device marked as ${status}`); this.lastDeviceStatus = status; } const { error } = await this.client .from('mcp_devices') .update({ status: status, last_seen: new Date().toISOString() }) .eq('id', deviceId); if (error) { console.error(`[DEBUG] Failed to set status ${status}:`, error.message); if (status == "online") { console.error('Failed to update device status:', error.message); } await captureRemote('remote_channel_status_update_error', { error, status }); return; } else { console.debug(`[DEBUG] Device status set to ${status}`); } // console.log(status === 'online' ? `🔌 Device marked as ${status}` : `❌ Device marked as ${status}`); } async setOffline(deviceId) { if (!deviceId || !this.client) { console.debug('[DEBUG] setOffline() skipped - no deviceId or client'); return; } console.debug('[DEBUG] setOffline() initiating blocking update for device:', deviceId); try { // Get current session for the subprocess const { data: sessionData } = await this.client.auth.getSession(); if (!sessionData?.session?.access_token) { console.error('❌ No valid session for offline update'); console.debug('[DEBUG] Session data missing or invalid'); return; } // Get Supabase config from client const supabaseUrl = this.client.supabaseUrl; const supabaseKey = this.client.supabaseKey; if (!supabaseUrl || !supabaseKey) { console.error('❌ Missing Supabase configuration'); console.debug('[DEBUG] supabaseUrl or supabaseKey is missing'); return; } // Use spawnSync to run the blocking update script const { spawnSync } = await import('child_process'); const { fileURLToPath } = await import('url'); const path = await import('path'); // Get the script path relative to this file const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const scriptPath = path.join(__dirname, 'scripts', 'blocking-offline-update.js'); console.debug('[DEBUG] Spawning blocking update script:', scriptPath); console.debug('[DEBUG] Using node executable:', process.execPath); const result = spawnSync('node', [ scriptPath, deviceId, supabaseUrl, supabaseKey, sessionData.session.access_token, sessionData.session.refresh_token || '' ], { timeout: 3000, stdio: 'pipe', // Capture output to prevent blocking encoding: 'utf-8' }); console.debug('[DEBUG] spawnSync completed, exit code:', result.status, 'signal:', result.signal); // Log subprocess output (with encoding:'utf-8', these are already strings) if (result.stdout && result.stdout.trim()) { console.log(result.stdout.trim()); } if (result.stderr && result.stderr.trim()) { console.error(result.stderr.trim()); } // Handle exit codes if (result.error) { console.error('❌ Failed to spawn update process:', result.error.message); console.debug('[DEBUG] spawn error:', result.error); } else if (result.status === 0) { console.log('✓ Device marked as offline (blocking)'); } else if (result.status === 2) { console.warn('⚠️ Device offline update timed out'); } else if (result.signal) { console.error(`❌ Update process killed by signal: ${result.signal}`); } else { console.error(`❌ Update process failed with exit code: ${result.status}`); } } catch (error) { console.error('❌ Error in blocking offline update:', error.message); console.debug('[DEBUG] setOffline() error stack:', error.stack); await captureRemote('remote_channel_offline_update_error', { error }); } } async unsubscribe() { if (this.channel) { await this.channel.unsubscribe(); this.channel = null; console.log('✓ Unsubscribed from tool call channel'); } } }