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.
This commit is contained in:
Haze
2026-02-05 23:15:07 +08:00
Unverified
parent b8ab0208d0
commit 1646536e40
15 changed files with 601 additions and 74 deletions

View File

@@ -2,7 +2,7 @@
* Gateway WebSocket Client * Gateway WebSocket Client
* Provides a typed interface for Gateway RPC calls * Provides a typed interface for Gateway RPC calls
*/ */
import { GatewayManager } from './manager'; import { GatewayManager, GatewayStatus } from './manager';
/** /**
* Channel types supported by OpenClaw * Channel types supported by OpenClaw
@@ -19,6 +19,7 @@ export interface Channel {
status: 'connected' | 'disconnected' | 'connecting' | 'error'; status: 'connected' | 'disconnected' | 'connecting' | 'error';
lastActivity?: string; lastActivity?: string;
error?: string; error?: string;
config?: Record<string, unknown>;
} }
/** /**
@@ -32,6 +33,20 @@ export interface Skill {
category?: string; category?: string;
icon?: string; icon?: string;
configurable?: boolean; 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; timestamp: string;
channel?: string; channel?: string;
toolCalls?: ToolCall[]; toolCalls?: ToolCall[];
metadata?: Record<string, unknown>;
} }
/** /**
@@ -55,6 +71,35 @@ export interface ToolCall {
arguments: Record<string, unknown>; arguments: Record<string, unknown>;
result?: unknown; result?: unknown;
status: 'pending' | 'running' | 'completed' | 'error'; 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 { export class GatewayClient {
constructor(private manager: GatewayManager) {} 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 ==================== // ==================== Channel Methods ====================
/** /**
@@ -161,13 +220,80 @@ export class GatewayClient {
return this.manager.rpc<void>('chat.clear'); return this.manager.rpc<void>('chat.clear');
} }
// ==================== Cron Methods ====================
/**
* List all cron tasks
*/
async listCronTasks(): Promise<CronTask[]> {
return this.manager.rpc<CronTask[]>('cron.list');
}
/**
* Create a new cron task
*/
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.create', task);
}
/**
* Update a cron task
*/
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
}
/**
* Delete a cron task
*/
async deleteCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.delete', { taskId });
}
/**
* Run a cron task immediately
*/
async runCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.run', { taskId });
}
// ==================== Provider Methods ====================
/**
* List configured AI providers
*/
async listProviders(): Promise<ProviderConfig[]> {
return this.manager.rpc<ProviderConfig[]>('providers.list');
}
/**
* Add or update a provider
*/
async setProvider(provider: ProviderConfig): Promise<void> {
return this.manager.rpc<void>('providers.set', provider);
}
/**
* Remove a provider
*/
async removeProvider(providerId: string): Promise<void> {
return this.manager.rpc<void>('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 ==================== // ==================== System Methods ====================
/** /**
* Get Gateway health status * Get Gateway health status
*/ */
async getHealth(): Promise<{ status: string; uptime: number }> { async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
return this.manager.rpc<{ status: string; uptime: number }>('system.health'); return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
} }
/** /**
@@ -183,4 +309,25 @@ export class GatewayClient {
async updateConfig(config: Record<string, unknown>): Promise<void> { async updateConfig(config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('system.updateConfig', config); return this.manager.rpc<void>('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<SkillBundle[]> {
return this.manager.rpc<SkillBundle[]>('skills.bundles');
}
/**
* Install a skill bundle
*/
async installBundle(bundleId: string): Promise<void> {
return this.manager.rpc<void>('skills.installBundle', { bundleId });
}
} }

View File

@@ -2,24 +2,24 @@
* Gateway Process Manager * Gateway Process Manager
* Manages the OpenClaw Gateway process lifecycle * Manages the OpenClaw Gateway process lifecycle
*/ */
import { spawn, ChildProcess, exec } from 'child_process'; import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import WebSocket from 'ws'; import WebSocket from 'ws';
import { promisify } from 'util';
import { PORTS } from '../utils/config'; import { PORTS } from '../utils/config';
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
const execAsync = promisify(exec);
/** /**
* Gateway connection status * Gateway connection status
*/ */
export interface GatewayStatus { export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error'; state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
port: number; port: number;
pid?: number; pid?: number;
uptime?: number; uptime?: number;
error?: string; error?: string;
connectedAt?: number; connectedAt?: number;
version?: string;
reconnectAttempts?: number;
} }
/** /**
@@ -28,10 +28,28 @@ export interface GatewayStatus {
export interface GatewayManagerEvents { export interface GatewayManagerEvents {
status: (status: GatewayStatus) => void; status: (status: GatewayStatus) => void;
message: (message: unknown) => void; message: (message: unknown) => void;
notification: (notification: JsonRpcNotification) => void;
exit: (code: number | null) => void; exit: (code: number | null) => void;
error: (error: Error) => 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 * Gateway Manager
* Handles starting, stopping, and communicating with the OpenClaw Gateway * 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 status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
private reconnectTimer: NodeJS.Timeout | null = null; private reconnectTimer: NodeJS.Timeout | null = null;
private pingInterval: 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<string, { private pendingRequests: Map<string, {
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timeout: NodeJS.Timeout; timeout: NodeJS.Timeout;
}> = new Map(); }> = new Map();
constructor() { constructor(config?: Partial<ReconnectConfig>) {
super(); super();
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
} }
/** /**
@@ -59,6 +82,13 @@ export class GatewayManager extends EventEmitter {
return { ...this.status }; 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 * Start Gateway process
*/ */
@@ -67,7 +97,9 @@ export class GatewayManager extends EventEmitter {
return; return;
} }
this.setStatus({ state: 'starting' }); this.shouldReconnect = true;
this.reconnectAttempts = 0;
this.setStatus({ state: 'starting', reconnectAttempts: 0 });
try { try {
// Check if Gateway is already running // Check if Gateway is already running
@@ -75,6 +107,7 @@ export class GatewayManager extends EventEmitter {
if (existing) { if (existing) {
console.log('Found existing Gateway on port', existing.port); console.log('Found existing Gateway on port', existing.port);
await this.connect(existing.port); await this.connect(existing.port);
this.startHealthCheck();
return; return;
} }
@@ -87,6 +120,9 @@ export class GatewayManager extends EventEmitter {
// Connect WebSocket // Connect WebSocket
await this.connect(this.status.port); await this.connect(this.status.port);
// Start health monitoring
this.startHealthCheck();
} catch (error) { } catch (error) {
this.setStatus({ state: 'error', error: String(error) }); this.setStatus({ state: 'error', error: String(error) });
throw error; throw error;
@@ -97,7 +133,56 @@ export class GatewayManager extends EventEmitter {
* Stop Gateway process * Stop Gateway process
*/ */
async stop(): Promise<void> { async stop(): Promise<void> {
// 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<void> {
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) { if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer); clearTimeout(this.reconnectTimer);
this.reconnectTimer = null; this.reconnectTimer = null;
@@ -106,33 +191,16 @@ export class GatewayManager extends EventEmitter {
clearInterval(this.pingInterval); clearInterval(this.pingInterval);
this.pingInterval = null; this.pingInterval = null;
} }
if (this.healthCheckInterval) {
// Close WebSocket clearInterval(this.healthCheckInterval);
if (this.ws) { this.healthCheckInterval = null;
this.ws.close();
this.ws = 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 * Make an RPC call to the Gateway
*/ */
async rpc<T>(method: string, params?: unknown): Promise<T> { async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
reject(new Error('Gateway not connected')); reject(new Error('Gateway not connected'));
@@ -145,7 +213,7 @@ export class GatewayManager extends EventEmitter {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.pendingRequests.delete(id); this.pendingRequests.delete(id);
reject(new Error(`RPC timeout: ${method}`)); reject(new Error(`RPC timeout: ${method}`));
}, 30000); }, timeoutMs);
// Store pending request // Store pending request
this.pendingRequests.set(id, { this.pendingRequests.set(id, {
@@ -162,10 +230,61 @@ export class GatewayManager extends EventEmitter {
params, 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 * Find existing Gateway process
*/ */
@@ -307,25 +426,61 @@ export class GatewayManager extends EventEmitter {
/** /**
* Handle incoming WebSocket message * Handle incoming WebSocket message
*/ */
private handleMessage(message: { id?: string; result?: unknown; error?: unknown }): void { private handleMessage(message: unknown): void {
// Check if this is a response to a pending request // Check if this is a JSON-RPC response
if (message.id && this.pendingRequests.has(message.id)) { if (isResponse(message) && message.id && this.pendingRequests.has(String(message.id))) {
const request = this.pendingRequests.get(message.id)!; const request = this.pendingRequests.get(String(message.id))!;
clearTimeout(request.timeout); clearTimeout(request.timeout);
this.pendingRequests.delete(message.id); this.pendingRequests.delete(String(message.id));
if (message.error) { 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 { } else {
request.resolve(message.result); request.resolve(message.result);
} }
return; 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); 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 * 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 { private scheduleReconnect(): void {
if (!this.shouldReconnect) {
console.log('Auto-reconnect disabled, not scheduling reconnect');
return;
}
if (this.reconnectTimer) { if (this.reconnectTimer) {
return; 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 = setTimeout(async () => {
this.reconnectTimer = null; this.reconnectTimer = null;
try { 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) { } catch (error) {
console.error('Reconnection failed:', error); console.error('Reconnection failed:', error);
this.scheduleReconnect(); this.scheduleReconnect();
} }
}, 5000); }, delay);
} }
/** /**
* Update status and emit event * Update status and emit event
*/ */
private setStatus(update: Partial<GatewayStatus>): void { private setStatus(update: Partial<GatewayStatus>): void {
const previousState = this.status.state;
this.status = { ...this.status, ...update }; 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); this.emit('status', this.status);
// Log state transitions
if (previousState !== this.status.state) {
console.log(`Gateway state: ${previousState} -> ${this.status.state}`);
}
} }
} }

View File

@@ -37,6 +37,11 @@ function registerGatewayHandlers(
return gatewayManager.getStatus(); return gatewayManager.getStatus();
}); });
// Check if Gateway is connected
ipcMain.handle('gateway:isConnected', () => {
return gatewayManager.isConnected();
});
// Start Gateway // Start Gateway
ipcMain.handle('gateway:start', async () => { ipcMain.handle('gateway:start', async () => {
try { try {
@@ -60,8 +65,7 @@ function registerGatewayHandlers(
// Restart Gateway // Restart Gateway
ipcMain.handle('gateway:restart', async () => { ipcMain.handle('gateway:restart', async () => {
try { try {
await gatewayManager.stop(); await gatewayManager.restart();
await gatewayManager.start();
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { success: false, error: String(error) }; return { success: false, error: String(error) };
@@ -69,26 +73,66 @@ function registerGatewayHandlers(
}); });
// Gateway RPC call // Gateway RPC call
ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown) => { ipcMain.handle('gateway:rpc', async (_, method: string, params?: unknown, timeoutMs?: number) => {
try { try {
const result = await gatewayManager.rpc(method, params); const result = await gatewayManager.rpc(method, params, timeoutMs);
return { success: true, result }; return { success: true, result };
} catch (error) { } catch (error) {
return { success: false, error: String(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) => { 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) => { 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) => { 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);
}
}); });
} }

View File

@@ -16,10 +16,12 @@ const electronAPI = {
const validChannels = [ const validChannels = [
// Gateway // Gateway
'gateway:status', 'gateway:status',
'gateway:isConnected',
'gateway:start', 'gateway:start',
'gateway:stop', 'gateway:stop',
'gateway:restart', 'gateway:restart',
'gateway:rpc', 'gateway:rpc',
'gateway:health',
// Shell // Shell
'shell:openExternal', 'shell:openExternal',
'shell:showItemInFolder', 'shell:showItemInFolder',
@@ -74,6 +76,9 @@ const electronAPI = {
const validChannels = [ const validChannels = [
'gateway:status-changed', 'gateway:status-changed',
'gateway:message', 'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit', 'gateway:exit',
'gateway:error', 'gateway:error',
'navigate', 'navigate',
@@ -106,6 +111,9 @@ const electronAPI = {
const validChannels = [ const validChannels = [
'gateway:status-changed', 'gateway:status-changed',
'gateway:message', 'gateway:message',
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'gateway:exit', 'gateway:exit',
'gateway:error', 'gateway:error',
'navigate', 'navigate',

View File

@@ -28,8 +28,11 @@ function App() {
// Listen for navigation events from main process // Listen for navigation events from main process
useEffect(() => { useEffect(() => {
const handleNavigate = (path: string) => { const handleNavigate = (...args: unknown[]) => {
navigate(path); const path = args[0];
if (typeof path === 'string') {
navigate(path);
}
}; };
const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate); const unsubscribe = window.electron.ipcRenderer.on('navigate', handleNavigate);

View File

@@ -5,7 +5,7 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; 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 { interface StatusBadgeProps {
status: Status; status: Status;
@@ -20,6 +20,7 @@ const statusConfig: Record<Status, { label: string; variant: 'success' | 'second
stopped: { label: 'Stopped', variant: 'secondary' }, stopped: { label: 'Stopped', variant: 'secondary' },
connecting: { label: 'Connecting', variant: 'warning' }, connecting: { label: 'Connecting', variant: 'warning' },
starting: { label: 'Starting', variant: 'warning' }, starting: { label: 'Starting', variant: 'warning' },
reconnecting: { label: 'Reconnecting', variant: 'warning' },
error: { label: 'Error', variant: 'destructive' }, error: { label: 'Error', variant: 'destructive' },
}; };

View File

@@ -3,7 +3,7 @@
* Top navigation bar with search and actions * Top navigation bar with search and actions
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { Search, Bell, Moon, Sun, Monitor } from 'lucide-react'; import { Search, Bell, Moon, Sun, Monitor } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -21,7 +21,6 @@ const pageTitles: Record<string, string> = {
export function Header() { export function Header() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const theme = useSettingsStore((state) => state.theme); const theme = useSettingsStore((state) => state.theme);
const setTheme = useSettingsStore((state) => state.setTheme); const setTheme = useSettingsStore((state) => state.setTheme);

View File

@@ -3,7 +3,7 @@
* Navigation sidebar with menu items * Navigation sidebar with menu items
*/ */
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { import {
Home, Home,
MessageSquare, MessageSquare,
@@ -62,7 +62,6 @@ function NavItem({ to, icon, label, badge, collapsed }: NavItemProps) {
} }
export function Sidebar() { export function Sidebar() {
const location = useLocation();
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);

View File

@@ -2,7 +2,7 @@
* Cron Page * Cron Page
* Manage scheduled tasks * Manage scheduled tasks
*/ */
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react'; import { Plus, Clock, Play, Pause, Trash2, Edit, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';

View File

@@ -11,8 +11,6 @@ import {
Clock, Clock,
Settings, Settings,
Plus, Plus,
RefreshCw,
ExternalLink,
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; 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 { useChannelsStore } from '@/stores/channels';
import { useSkillsStore } from '@/stores/skills'; import { useSkillsStore } from '@/stores/skills';
import { StatusBadge } from '@/components/common/StatusBadge'; import { StatusBadge } from '@/components/common/StatusBadge';
import { formatRelativeTime } from '@/lib/utils';
export function Dashboard() { export function Dashboard() {
const gatewayStatus = useGatewayStore((state) => state.status); const gatewayStatus = useGatewayStore((state) => state.status);

View File

@@ -11,7 +11,6 @@ import {
Loader2, Loader2,
Terminal, Terminal,
ExternalLink, ExternalLink,
Info,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';

View File

@@ -20,7 +20,7 @@ interface CronState {
setJobs: (jobs: CronJob[]) => void; setJobs: (jobs: CronJob[]) => void;
} }
export const useCronStore = create<CronState>((set, get) => ({ export const useCronStore = create<CronState>((set) => ({
jobs: [], jobs: [],
loading: false, loading: false,
error: null, error: null,

View File

@@ -1,20 +1,31 @@
/** /**
* Gateway State Store * Gateway State Store
* Manages Gateway connection state * Manages Gateway connection state and communication
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import type { GatewayStatus } from '../types/gateway'; import type { GatewayStatus } from '../types/gateway';
interface GatewayHealth {
ok: boolean;
error?: string;
uptime?: number;
}
interface GatewayState { interface GatewayState {
status: GatewayStatus; status: GatewayStatus;
health: GatewayHealth | null;
isInitialized: boolean; isInitialized: boolean;
lastError: string | null;
// Actions // Actions
init: () => Promise<void>; init: () => Promise<void>;
start: () => Promise<void>; start: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;
restart: () => Promise<void>; restart: () => Promise<void>;
checkHealth: () => Promise<GatewayHealth>;
rpc: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
setStatus: (status: GatewayStatus) => void; setStatus: (status: GatewayStatus) => void;
clearError: () => void;
} }
export const useGatewayStore = create<GatewayState>((set, get) => ({ export const useGatewayStore = create<GatewayState>((set, get) => ({
@@ -22,7 +33,9 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
state: 'stopped', state: 'stopped',
port: 18789, port: 18789,
}, },
health: null,
isInitialized: false, isInitialized: false,
lastError: null,
init: async () => { init: async () => {
if (get().isInitialized) return; if (get().isInitialized) return;
@@ -36,38 +49,111 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => { window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
set({ status: newStatus as GatewayStatus }); 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) { } catch (error) {
console.error('Failed to initialize Gateway:', error); console.error('Failed to initialize Gateway:', error);
set({ lastError: String(error) });
} }
}, },
start: async () => { start: async () => {
try { 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 }; const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string };
if (!result.success) { 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) { } 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 () => { stop: async () => {
try { try {
await window.electron.ipcRenderer.invoke('gateway:stop'); await window.electron.ipcRenderer.invoke('gateway:stop');
set({ status: { ...get().status, state: 'stopped' } }); set({ status: { ...get().status, state: 'stopped' }, lastError: null });
} catch (error) { } catch (error) {
console.error('Failed to stop Gateway:', error); console.error('Failed to stop Gateway:', error);
set({ lastError: String(error) });
} }
}, },
restart: async () => { restart: async () => {
const { stop, start } = get(); try {
await stop(); set({ status: { ...get().status, state: 'starting' }, lastError: null });
await start(); 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 <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
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 }), setStatus: (status) => set({ status }),
clearError: () => set({ lastError: null }),
})); }));

View File

@@ -7,12 +7,14 @@
* Gateway connection status * Gateway connection status
*/ */
export interface GatewayStatus { export interface GatewayStatus {
state: 'stopped' | 'starting' | 'running' | 'error'; state: 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
port: number; port: number;
pid?: number; pid?: number;
uptime?: number; uptime?: number;
error?: string; error?: string;
connectedAt?: number; connectedAt?: number;
version?: string;
reconnectAttempts?: number;
} }
/** /**
@@ -23,3 +25,34 @@ export interface GatewayRpcResponse<T = unknown> {
result?: T; result?: T;
error?: string; 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;
}

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true,
"target": "ES2022", "target": "ES2022",
"lib": ["ES2022"], "lib": ["ES2022"],
"module": "ESNext", "module": "ESNext",