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:
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +71,35 @@ export interface ToolCall {
|
||||
arguments: Record<string, unknown>;
|
||||
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<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 ====================
|
||||
|
||||
/**
|
||||
* 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<string, unknown>): Promise<void> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}> = new Map();
|
||||
|
||||
constructor() {
|
||||
constructor(config?: Partial<ReconnectConfig>) {
|
||||
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<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) {
|
||||
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<T>(method: string, params?: unknown): Promise<T> {
|
||||
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
|
||||
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<GatewayStatus>): 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Status, { label: string; variant: 'success' | 'second
|
||||
stopped: { label: 'Stopped', variant: 'secondary' },
|
||||
connecting: { label: 'Connecting', variant: 'warning' },
|
||||
starting: { label: 'Starting', variant: 'warning' },
|
||||
reconnecting: { label: 'Reconnecting', variant: 'warning' },
|
||||
error: { label: 'Error', variant: 'destructive' },
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Top navigation bar with search and actions
|
||||
*/
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -21,7 +21,6 @@ const pageTitles: Record<string, string> = {
|
||||
|
||||
export function Header() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
const setTheme = useSettingsStore((state) => state.setTheme);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -20,7 +20,7 @@ interface CronState {
|
||||
setJobs: (jobs: CronJob[]) => void;
|
||||
}
|
||||
|
||||
export const useCronStore = create<CronState>((set, get) => ({
|
||||
export const useCronStore = create<CronState>((set) => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -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<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
checkHealth: () => Promise<GatewayHealth>;
|
||||
rpc: <T>(method: string, params?: unknown, timeoutMs?: number) => Promise<T>;
|
||||
setStatus: (status: GatewayStatus) => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
@@ -22,7 +33,9 @@ export const useGatewayStore = create<GatewayState>((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<GatewayState>((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 <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 }),
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
}));
|
||||
|
||||
@@ -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<T = unknown> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
|
||||
Reference in New Issue
Block a user