From 1646536e4067fdf4915480cfbbe69f2686b0f63f Mon Sep 17 00:00:00 2001 From: Haze <709547807@qq.com> Date: Thu, 5 Feb 2026 23:15:07 +0800 Subject: [PATCH] feat(gateway): enhance gateway process management with auto-reconnection Improve Gateway lifecycle management with the following features: - Add exponential backoff reconnection (1s-30s delay, max 10 attempts) - Add health check monitoring every 30 seconds - Add proper restart method with graceful shutdown - Handle server-initiated notifications (channel status, chat messages) - Add 'reconnecting' state for better UI feedback - Enhance IPC handlers with isConnected and health check endpoints - Update preload script with new event channels - Improve type safety and error handling throughout Also fixes several TypeScript errors and unused variable warnings. --- electron/gateway/client.ts | 153 +++++++++++++- electron/gateway/manager.ts | 292 ++++++++++++++++++++++---- electron/main/ipc-handlers.ts | 60 +++++- electron/preload/index.ts | 8 + src/App.tsx | 7 +- src/components/common/StatusBadge.tsx | 3 +- src/components/layout/Header.tsx | 3 +- src/components/layout/Sidebar.tsx | 3 +- src/pages/Cron/index.tsx | 2 +- src/pages/Dashboard/index.tsx | 3 - src/pages/Settings/index.tsx | 1 - src/stores/cron.ts | 2 +- src/stores/gateway.ts | 102 ++++++++- src/types/gateway.ts | 35 ++- tsconfig.node.json | 1 + 15 files changed, 601 insertions(+), 74 deletions(-) diff --git a/electron/gateway/client.ts b/electron/gateway/client.ts index e8864bb5c..27e0ece1f 100644 --- a/electron/gateway/client.ts +++ b/electron/gateway/client.ts @@ -2,7 +2,7 @@ * Gateway WebSocket Client * Provides a typed interface for Gateway RPC calls */ -import { GatewayManager } from './manager'; +import { GatewayManager, GatewayStatus } from './manager'; /** * Channel types supported by OpenClaw @@ -19,6 +19,7 @@ export interface Channel { status: 'connected' | 'disconnected' | 'connecting' | 'error'; lastActivity?: string; error?: string; + config?: Record; } /** @@ -32,6 +33,20 @@ export interface Skill { category?: string; icon?: string; configurable?: boolean; + version?: string; + author?: string; +} + +/** + * Skill bundle definition + */ +export interface SkillBundle { + id: string; + name: string; + description: string; + skills: string[]; + icon?: string; + recommended?: boolean; } /** @@ -44,6 +59,7 @@ export interface ChatMessage { timestamp: string; channel?: string; toolCalls?: ToolCall[]; + metadata?: Record; } /** @@ -55,6 +71,35 @@ export interface ToolCall { arguments: Record; result?: unknown; status: 'pending' | 'running' | 'completed' | 'error'; + duration?: number; +} + +/** + * Cron task definition + */ +export interface CronTask { + id: string; + name: string; + schedule: string; + command: string; + enabled: boolean; + lastRun?: string; + nextRun?: string; + status: 'idle' | 'running' | 'error'; + error?: string; +} + +/** + * Provider configuration + */ +export interface ProviderConfig { + id: string; + name: string; + type: 'openai' | 'anthropic' | 'ollama' | 'custom'; + apiKey?: string; + baseUrl?: string; + model?: string; + enabled: boolean; } /** @@ -64,6 +109,20 @@ export interface ToolCall { export class GatewayClient { constructor(private manager: GatewayManager) {} + /** + * Get current gateway status + */ + getStatus(): GatewayStatus { + return this.manager.getStatus(); + } + + /** + * Check if gateway is connected + */ + isConnected(): boolean { + return this.manager.isConnected(); + } + // ==================== Channel Methods ==================== /** @@ -161,13 +220,80 @@ export class GatewayClient { return this.manager.rpc('chat.clear'); } + // ==================== Cron Methods ==================== + + /** + * List all cron tasks + */ + async listCronTasks(): Promise { + return this.manager.rpc('cron.list'); + } + + /** + * Create a new cron task + */ + async createCronTask(task: Omit): Promise { + return this.manager.rpc('cron.create', task); + } + + /** + * Update a cron task + */ + async updateCronTask(taskId: string, updates: Partial): Promise { + return this.manager.rpc('cron.update', { taskId, ...updates }); + } + + /** + * Delete a cron task + */ + async deleteCronTask(taskId: string): Promise { + return this.manager.rpc('cron.delete', { taskId }); + } + + /** + * Run a cron task immediately + */ + async runCronTask(taskId: string): Promise { + return this.manager.rpc('cron.run', { taskId }); + } + + // ==================== Provider Methods ==================== + + /** + * List configured AI providers + */ + async listProviders(): Promise { + return this.manager.rpc('providers.list'); + } + + /** + * Add or update a provider + */ + async setProvider(provider: ProviderConfig): Promise { + return this.manager.rpc('providers.set', provider); + } + + /** + * Remove a provider + */ + async removeProvider(providerId: string): Promise { + return this.manager.rpc('providers.remove', { providerId }); + } + + /** + * Test provider connection + */ + async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> { + return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId }); + } + // ==================== System Methods ==================== /** * Get Gateway health status */ - async getHealth(): Promise<{ status: string; uptime: number }> { - return this.manager.rpc<{ status: string; uptime: number }>('system.health'); + async getHealth(): Promise<{ status: string; uptime: number; version?: string }> { + return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health'); } /** @@ -183,4 +309,25 @@ export class GatewayClient { async updateConfig(config: Record): Promise { return this.manager.rpc('system.updateConfig', config); } + + /** + * Get Gateway version info + */ + async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> { + return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version'); + } + + /** + * Get available skill bundles + */ + async getSkillBundles(): Promise { + return this.manager.rpc('skills.bundles'); + } + + /** + * Install a skill bundle + */ + async installBundle(bundleId: string): Promise { + return this.manager.rpc('skills.installBundle', { bundleId }); + } } diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 1f07177f0..cc15919d4 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -2,24 +2,24 @@ * Gateway Process Manager * Manages the OpenClaw Gateway process lifecycle */ -import { spawn, ChildProcess, exec } from 'child_process'; +import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import WebSocket from 'ws'; -import { promisify } from 'util'; import { PORTS } from '../utils/config'; - -const execAsync = promisify(exec); +import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; /** * Gateway connection status */ export interface GatewayStatus { - state: 'stopped' | 'starting' | 'running' | 'error'; + state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; port: number; pid?: number; uptime?: number; error?: string; connectedAt?: number; + version?: string; + reconnectAttempts?: number; } /** @@ -28,10 +28,28 @@ export interface GatewayStatus { export interface GatewayManagerEvents { status: (status: GatewayStatus) => void; message: (message: unknown) => void; + notification: (notification: JsonRpcNotification) => void; exit: (code: number | null) => void; error: (error: Error) => void; + 'channel:status': (data: { channelId: string; status: string }) => void; + 'chat:message': (data: { message: unknown }) => void; } +/** + * Reconnection configuration + */ +interface ReconnectConfig { + maxAttempts: number; + baseDelay: number; + maxDelay: number; +} + +const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = { + maxAttempts: 10, + baseDelay: 1000, + maxDelay: 30000, +}; + /** * Gateway Manager * Handles starting, stopping, and communicating with the OpenClaw Gateway @@ -42,14 +60,19 @@ export class GatewayManager extends EventEmitter { private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY }; private reconnectTimer: NodeJS.Timeout | null = null; private pingInterval: NodeJS.Timeout | null = null; + private healthCheckInterval: NodeJS.Timeout | null = null; + private reconnectAttempts = 0; + private reconnectConfig: ReconnectConfig; + private shouldReconnect = true; private pendingRequests: Map void; reject: (error: Error) => void; timeout: NodeJS.Timeout; }> = new Map(); - constructor() { + constructor(config?: Partial) { super(); + this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config }; } /** @@ -59,6 +82,13 @@ export class GatewayManager extends EventEmitter { return { ...this.status }; } + /** + * Check if Gateway is connected and ready + */ + isConnected(): boolean { + return this.status.state === 'running' && this.ws?.readyState === WebSocket.OPEN; + } + /** * Start Gateway process */ @@ -67,7 +97,9 @@ export class GatewayManager extends EventEmitter { return; } - this.setStatus({ state: 'starting' }); + this.shouldReconnect = true; + this.reconnectAttempts = 0; + this.setStatus({ state: 'starting', reconnectAttempts: 0 }); try { // Check if Gateway is already running @@ -75,6 +107,7 @@ export class GatewayManager extends EventEmitter { if (existing) { console.log('Found existing Gateway on port', existing.port); await this.connect(existing.port); + this.startHealthCheck(); return; } @@ -87,6 +120,9 @@ export class GatewayManager extends EventEmitter { // Connect WebSocket await this.connect(this.status.port); + // Start health monitoring + this.startHealthCheck(); + } catch (error) { this.setStatus({ state: 'error', error: String(error) }); throw error; @@ -97,7 +133,56 @@ export class GatewayManager extends EventEmitter { * Stop Gateway process */ async stop(): Promise { - // Clear timers + // Disable auto-reconnect + this.shouldReconnect = false; + + // Clear all timers + this.clearAllTimers(); + + // Close WebSocket + if (this.ws) { + this.ws.close(1000, 'Gateway stopped by user'); + this.ws = null; + } + + // Kill process + if (this.process) { + this.process.kill('SIGTERM'); + // Force kill after timeout + setTimeout(() => { + if (this.process) { + this.process.kill('SIGKILL'); + this.process = null; + } + }, 5000); + this.process = null; + } + + // Reject all pending requests + for (const [, request] of this.pendingRequests) { + clearTimeout(request.timeout); + request.reject(new Error('Gateway stopped')); + } + this.pendingRequests.clear(); + + this.setStatus({ state: 'stopped', error: undefined }); + } + + /** + * Restart Gateway process + */ + async restart(): Promise { + console.log('Restarting Gateway...'); + await this.stop(); + // Brief delay before restart + await new Promise(resolve => setTimeout(resolve, 1000)); + await this.start(); + } + + /** + * Clear all active timers + */ + private clearAllTimers(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; @@ -106,33 +191,16 @@ export class GatewayManager extends EventEmitter { clearInterval(this.pingInterval); this.pingInterval = null; } - - // Close WebSocket - if (this.ws) { - this.ws.close(); - this.ws = null; + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; } - - // Kill process - if (this.process) { - this.process.kill(); - this.process = null; - } - - // Reject all pending requests - for (const [id, request] of this.pendingRequests) { - clearTimeout(request.timeout); - request.reject(new Error('Gateway stopped')); - } - this.pendingRequests.clear(); - - this.setStatus({ state: 'stopped' }); } /** * Make an RPC call to the Gateway */ - async rpc(method: string, params?: unknown): Promise { + async rpc(method: string, params?: unknown, timeoutMs = 30000): Promise { return new Promise((resolve, reject) => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { reject(new Error('Gateway not connected')); @@ -145,7 +213,7 @@ export class GatewayManager extends EventEmitter { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`RPC timeout: ${method}`)); - }, 30000); + }, timeoutMs); // Store pending request this.pendingRequests.set(id, { @@ -162,10 +230,61 @@ export class GatewayManager extends EventEmitter { params, }; - this.ws.send(JSON.stringify(request)); + try { + this.ws.send(JSON.stringify(request)); + } catch (error) { + this.pendingRequests.delete(id); + clearTimeout(timeout); + reject(new Error(`Failed to send RPC request: ${error}`)); + } }); } + /** + * Start health check monitoring + */ + private startHealthCheck(): void { + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + } + + this.healthCheckInterval = setInterval(async () => { + if (this.status.state !== 'running') { + return; + } + + try { + const health = await this.checkHealth(); + if (!health.ok) { + console.warn('Gateway health check failed:', health.error); + this.emit('error', new Error(health.error || 'Health check failed')); + } + } catch (error) { + console.error('Health check error:', error); + } + }, 30000); // Check every 30 seconds + } + + /** + * Check Gateway health via HTTP endpoint + */ + async checkHealth(): Promise<{ ok: boolean; error?: string; uptime?: number }> { + try { + const response = await fetch(`http://localhost:${this.status.port}/health`, { + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + const data = await response.json() as { uptime?: number }; + return { ok: true, uptime: data.uptime }; + } + + return { ok: false, error: `Health check returned ${response.status}` }; + } catch (error) { + return { ok: false, error: String(error) }; + } + } + /** * Find existing Gateway process */ @@ -307,25 +426,61 @@ export class GatewayManager extends EventEmitter { /** * Handle incoming WebSocket message */ - private handleMessage(message: { id?: string; result?: unknown; error?: unknown }): void { - // Check if this is a response to a pending request - if (message.id && this.pendingRequests.has(message.id)) { - const request = this.pendingRequests.get(message.id)!; + private handleMessage(message: unknown): void { + // Check if this is a JSON-RPC response + if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) { + const request = this.pendingRequests.get(String(message.id))!; clearTimeout(request.timeout); - this.pendingRequests.delete(message.id); + this.pendingRequests.delete(String(message.id)); if (message.error) { - request.reject(new Error(String(message.error))); + const errorMsg = typeof message.error === 'object' + ? (message.error as { message?: string }).message || JSON.stringify(message.error) + : String(message.error); + request.reject(new Error(errorMsg)); } else { request.resolve(message.result); } return; } - // Emit message for other handlers + // Check if this is a notification (server-initiated event) + if (isNotification(message)) { + this.handleNotification(message); + return; + } + + // Emit generic message for other handlers this.emit('message', message); } + /** + * Handle server-initiated notifications + */ + private handleNotification(notification: JsonRpcNotification): void { + this.emit('notification', notification); + + // Route specific events + switch (notification.method) { + case GatewayEventType.CHANNEL_STATUS_CHANGED: + this.emit('channel:status', notification.params as { channelId: string; status: string }); + break; + + case GatewayEventType.MESSAGE_RECEIVED: + this.emit('chat:message', notification.params as { message: unknown }); + break; + + case GatewayEventType.ERROR: + const errorData = notification.params as { message?: string }; + this.emit('error', new Error(errorData.message || 'Gateway error')); + break; + + default: + // Unknown notification type, just log it + console.log('Unknown Gateway notification:', notification.method); + } + } + /** * Start ping interval to keep connection alive */ @@ -342,29 +497,84 @@ export class GatewayManager extends EventEmitter { } /** - * Schedule reconnection attempt + * Schedule reconnection attempt with exponential backoff */ private scheduleReconnect(): void { + if (!this.shouldReconnect) { + console.log('Auto-reconnect disabled, not scheduling reconnect'); + return; + } + if (this.reconnectTimer) { return; } + if (this.reconnectAttempts >= this.reconnectConfig.maxAttempts) { + console.error(`Max reconnection attempts (${this.reconnectConfig.maxAttempts}) reached`); + this.setStatus({ + state: 'error', + error: 'Failed to reconnect after maximum attempts', + reconnectAttempts: this.reconnectAttempts + }); + return; + } + + // Calculate delay with exponential backoff + const delay = Math.min( + this.reconnectConfig.baseDelay * Math.pow(2, this.reconnectAttempts), + this.reconnectConfig.maxDelay + ); + + this.reconnectAttempts++; + console.log(`Scheduling reconnect attempt ${this.reconnectAttempts} in ${delay}ms`); + + this.setStatus({ + state: 'reconnecting', + reconnectAttempts: this.reconnectAttempts + }); + this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; try { - await this.start(); + // Try to find existing Gateway first + const existing = await this.findExistingGateway(); + if (existing) { + await this.connect(existing.port); + this.reconnectAttempts = 0; + this.startHealthCheck(); + return; + } + + // Otherwise restart the process + await this.startProcess(); + await this.waitForReady(); + await this.connect(this.status.port); + this.reconnectAttempts = 0; + this.startHealthCheck(); } catch (error) { console.error('Reconnection failed:', error); this.scheduleReconnect(); } - }, 5000); + }, delay); } /** * Update status and emit event */ private setStatus(update: Partial): void { + const previousState = this.status.state; this.status = { ...this.status, ...update }; + + // Calculate uptime if connected + if (this.status.state === 'running' && this.status.connectedAt) { + this.status.uptime = Date.now() - this.status.connectedAt; + } + this.emit('status', this.status); + + // Log state transitions + if (previousState !== this.status.state) { + console.log(`Gateway state: ${previousState} -> ${this.status.state}`); + } } } diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 23812b1dc..8913e0d4d 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -37,6 +37,11 @@ function registerGatewayHandlers( return gatewayManager.getStatus(); }); + // Check if Gateway is connected + ipcMain.handle('gateway:isConnected', () => { + return gatewayManager.isConnected(); + }); + // Start Gateway ipcMain.handle('gateway:start', async () => { try { @@ -60,8 +65,7 @@ function registerGatewayHandlers( // Restart Gateway ipcMain.handle('gateway:restart', async () => { try { - await gatewayManager.stop(); - await gatewayManager.start(); + await gatewayManager.restart(); return { success: true }; } catch (error) { return { success: false, error: String(error) }; @@ -69,26 +73,66 @@ function registerGatewayHandlers( }); // Gateway RPC call - ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown) => { + ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => { try { - const result = await gatewayManager.rpc(method, params); + const result = await gatewayManager.rpc(method, params, timeoutMs); return { success: true, result }; } catch (error) { return { success: false, error: String(error) }; } }); - // Forward Gateway status events to renderer + // Health check + ipcMain.handle('gateway:health', async () => { + try { + const health = await gatewayManager.checkHealth(); + return { success: true, ...health }; + } catch (error) { + return { success: false, ok: false, error: String(error) }; + } + }); + + // Forward Gateway events to renderer gatewayManager.on('status', (status) => { - mainWindow.webContents.send('gateway:status-changed', status); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:status-changed', status); + } }); gatewayManager.on('message', (message) => { - mainWindow.webContents.send('gateway:message', message); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:message', message); + } + }); + + gatewayManager.on('notification', (notification) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:notification', notification); + } + }); + + gatewayManager.on('channel:status', (data) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:channel-status', data); + } + }); + + gatewayManager.on('chat:message', (data) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:chat-message', data); + } }); gatewayManager.on('exit', (code) => { - mainWindow.webContents.send('gateway:exit', code); + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:exit', code); + } + }); + + gatewayManager.on('error', (error) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('gateway:error', error.message); + } }); } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index cb1b017af..7db323bc3 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -16,10 +16,12 @@ const electronAPI = { const validChannels = [ // Gateway 'gateway:status', + 'gateway:isConnected', 'gateway:start', 'gateway:stop', 'gateway:restart', 'gateway:rpc', + 'gateway:health', // Shell 'shell:openExternal', 'shell:showItemInFolder', @@ -74,6 +76,9 @@ const electronAPI = { const validChannels = [ 'gateway:status-changed', 'gateway:message', + 'gateway:notification', + 'gateway:channel-status', + 'gateway:chat-message', 'gateway:exit', 'gateway:error', 'navigate', @@ -106,6 +111,9 @@ const electronAPI = { const validChannels = [ 'gateway:status-changed', 'gateway:message', + 'gateway:notification', + 'gateway:channel-status', + 'gateway:chat-message', 'gateway:exit', 'gateway:error', 'navigate', diff --git a/src/App.tsx b/src/App.tsx index 26e4d1bba..7df80edca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,8 +28,11 @@ function App() { // Listen for navigation events from main process useEffect(() => { - const handleNavigate = (path: string) => { - navigate(path); + const handleNavigate = (...args: unknown[]) => { + const path = args[0]; + if (typeof path === 'string') { + navigate(path); + } }; const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate); diff --git a/src/components/common/StatusBadge.tsx b/src/components/common/StatusBadge.tsx index 15da03d31..8eb2e8916 100644 --- a/src/components/common/StatusBadge.tsx +++ b/src/components/common/StatusBadge.tsx @@ -5,7 +5,7 @@ import { cn } from '@/lib/utils'; import { Badge } from '@/components/ui/badge'; -type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting'; +export type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting' | 'reconnecting'; interface StatusBadgeProps { status: Status; @@ -20,6 +20,7 @@ const statusConfig: Record = { export function Header() { const location = useLocation(); - const navigate = useNavigate(); const theme = useSettingsStore((state) => state.theme); const setTheme = useSettingsStore((state) => state.setTheme); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 74811e9c4..473972311 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ * Navigation sidebar with menu items */ import { useState, useEffect } from 'react'; -import { NavLink, useLocation } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { Home, MessageSquare, @@ -62,7 +62,6 @@ function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) { } export function Sidebar() { - const location = useLocation(); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 6c5e0d60a..534402628 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -2,7 +2,7 @@ * Cron Page * Manage scheduled tasks */ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index e3d76267f..2729a44ae 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -11,8 +11,6 @@ import { Clock, Settings, Plus, - RefreshCw, - ExternalLink, } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -22,7 +20,6 @@ import { useGatewayStore } from '@/stores/gateway'; import { useChannelsStore } from '@/stores/channels'; import { useSkillsStore } from '@/stores/skills'; import { StatusBadge } from '@/components/common/StatusBadge'; -import { formatRelativeTime } from '@/lib/utils'; export function Dashboard() { const gatewayStatus = useGatewayStore((state) => state.status); diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 72d0ddb25..1fe926608 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -11,7 +11,6 @@ import { Loader2, Terminal, ExternalLink, - Info, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; diff --git a/src/stores/cron.ts b/src/stores/cron.ts index 0d4da284b..112661271 100644 --- a/src/stores/cron.ts +++ b/src/stores/cron.ts @@ -20,7 +20,7 @@ interface CronState { setJobs: (jobs: CronJob[]) => void; } -export const useCronStore = create((set, get) => ({ +export const useCronStore = create((set) => ({ jobs: [], loading: false, error: null, diff --git a/src/stores/gateway.ts b/src/stores/gateway.ts index 403bc7293..89368f4b5 100644 --- a/src/stores/gateway.ts +++ b/src/stores/gateway.ts @@ -1,20 +1,31 @@ /** * Gateway State Store - * Manages Gateway connection state + * Manages Gateway connection state and communication */ import { create } from 'zustand'; import type { GatewayStatus } from '../types/gateway'; +interface GatewayHealth { + ok: boolean; + error?: string; + uptime?: number; +} + interface GatewayState { status: GatewayStatus; + health: GatewayHealth | null; isInitialized: boolean; + lastError: string | null; // Actions init: () => Promise; start: () => Promise; stop: () => Promise; restart: () => Promise; + checkHealth: () => Promise; + rpc: (method: string, params?: unknown, timeoutMs?: number) => Promise; setStatus: (status: GatewayStatus) => void; + clearError: () => void; } export const useGatewayStore = create((set, get) => ({ @@ -22,7 +33,9 @@ export const useGatewayStore = create((set, get) => ({ state: 'stopped', port: 18789, }, + health: null, isInitialized: false, + lastError: null, init: async () => { if (get().isInitialized) return; @@ -36,38 +49,111 @@ export const useGatewayStore = create((set, get) => ({ window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { set({ status: newStatus as GatewayStatus }); }); + + // Listen for errors + window.electron.ipcRenderer.on('gateway:error', (error) => { + set({ lastError: String(error) }); + }); + + // Listen for notifications + window.electron.ipcRenderer.on('gateway:notification', (notification) => { + console.log('Gateway notification:', notification); + // Could dispatch to other stores based on notification type + }); + } catch (error) { console.error('Failed to initialize Gateway:', error); + set({ lastError: String(error) }); } }, start: async () => { try { - set({ status: { ...get().status, state: 'starting' } }); + set({ status: { ...get().status, state: 'starting' }, lastError: null }); const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string }; if (!result.success) { - set({ status: { ...get().status, state: 'error', error: result.error } }); + set({ + status: { ...get().status, state: 'error', error: result.error }, + lastError: result.error || 'Failed to start Gateway' + }); } } catch (error) { - set({ status: { ...get().status, state: 'error', error: String(error) } }); + set({ + status: { ...get().status, state: 'error', error: String(error) }, + lastError: String(error) + }); } }, stop: async () => { try { await window.electron.ipcRenderer.invoke('gateway:stop'); - set({ status: { ...get().status, state: 'stopped' } }); + set({ status: { ...get().status, state: 'stopped' }, lastError: null }); } catch (error) { console.error('Failed to stop Gateway:', error); + set({ lastError: String(error) }); } }, restart: async () => { - const { stop, start } = get(); - await stop(); - await start(); + try { + set({ status: { ...get().status, state: 'starting' }, lastError: null }); + const result = await window.electron.ipcRenderer.invoke('gateway:restart') as { success: boolean; error?: string }; + + if (!result.success) { + set({ + status: { ...get().status, state: 'error', error: result.error }, + lastError: result.error || 'Failed to restart Gateway' + }); + } + } catch (error) { + set({ + status: { ...get().status, state: 'error', error: String(error) }, + lastError: String(error) + }); + } + }, + + checkHealth: async () => { + try { + const result = await window.electron.ipcRenderer.invoke('gateway:health') as { + success: boolean; + ok: boolean; + error?: string; + uptime?: number + }; + + const health: GatewayHealth = { + ok: result.ok, + error: result.error, + uptime: result.uptime, + }; + + set({ health }); + return health; + } catch (error) { + const health: GatewayHealth = { ok: false, error: String(error) }; + set({ health }); + return health; + } + }, + + rpc: async (method: string, params?: unknown, timeoutMs?: number): Promise => { + const result = await window.electron.ipcRenderer.invoke('gateway:rpc', method, params, timeoutMs) as { + success: boolean; + result?: T; + error?: string; + }; + + if (!result.success) { + throw new Error(result.error || `RPC call failed: ${method}`); + } + + return result.result as T; }, setStatus: (status) => set({ status }), + + clearError: () => set({ lastError: null }), })); diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 2f304ed97..3decd24a1 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -7,12 +7,14 @@ * Gateway connection status */ export interface GatewayStatus { - state: 'stopped' | 'starting' | 'running' | 'error'; + state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting'; port: number; pid?: number; uptime?: number; error?: string; connectedAt?: number; + version?: string; + reconnectAttempts?: number; } /** @@ -23,3 +25,34 @@ export interface GatewayRpcResponse { result?: T; error?: string; } + +/** + * Gateway health check response + */ +export interface GatewayHealth { + ok: boolean; + error?: string; + uptime?: number; + version?: string; +} + +/** + * Gateway notification (server-initiated event) + */ +export interface GatewayNotification { + method: string; + params?: unknown; +} + +/** + * Provider configuration + */ +export interface ProviderConfig { + id: string; + name: string; + type: 'openai' | 'anthropic' | 'ollama' | 'custom'; + apiKey?: string; + baseUrl?: string; + model?: string; + enabled: boolean; +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 4f3cecf14..ff0ab827c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "composite": true, "target": "ES2022", "lib": ["ES2022"], "module": "ESNext",