feat: enhance after-pack script to copy OpenClaw runtime dependencies
- Added a new script `bundle-openclaw.mjs` to bundle OpenClaw runtime dependencies. - Updated `after-pack.cjs` to copy bundled OpenClaw runtime and its node_modules. - Improved cleanup of unnecessary development files in node_modules. - Adjusted paths for resources in the packaging process. style: update loading indicator styles in ChatHistoryPanel - Changed the border radius and padding for the loading indicator in ChatHistoryPanel. fix: improve ProvidersSection to handle provider account syncing - Added logic to sync model configuration to provider accounts. - Introduced error handling and loading states during the sync process. - Enhanced vendor resolution and account management logic. fix: fallback session handling in chat store - Implemented fallback session logic in loadSessions to ensure a valid session is always available on error.
This commit is contained in:
@@ -1,11 +1,31 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { windowManager } from '@electron/service/window-service';
|
||||
import logManager from '@electron/service/logger';
|
||||
import configManager from '@electron/service/config-service';
|
||||
import { updateTrayStatus } from '@electron/service/tray';
|
||||
import type { GatewayEvent, RuntimeRefreshTopic } from './types';
|
||||
import * as chatHandlers from './handlers/chat';
|
||||
import { getUserDataDir } from '@electron/utils/paths';
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
type DeviceIdentity,
|
||||
} from '@electron/utils/device-identity';
|
||||
import { CONFIG_KEYS } from '@runtime/lib/constants';
|
||||
import { normalizeAgentSessionKey } from '@runtime/lib/models';
|
||||
import type { ContentBlock, RawMessage } from '@runtime/shared/chat-model';
|
||||
import type { GatewayEvent, GatewayRpcParams, RuntimeRefreshTopic } from './types';
|
||||
import * as providerHandlers from './handlers/provider';
|
||||
import * as skillHandlers from './handlers/skills';
|
||||
import { OpenClawProcessOwner } from './openclaw-process-owner';
|
||||
import { launchGatewayProcess } from './process-launcher';
|
||||
import {
|
||||
clearPendingGatewayRequests,
|
||||
rejectPendingGatewayRequest,
|
||||
resolvePendingGatewayRequest,
|
||||
type PendingGatewayRequest,
|
||||
} from './request-store';
|
||||
import { connectGatewaySocket, waitForGatewayReady } from './ws-client';
|
||||
|
||||
type RuntimeChangeBroadcast = {
|
||||
topics: RuntimeRefreshTopic[];
|
||||
@@ -15,62 +35,697 @@ type RuntimeChangeBroadcast = {
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
type GatewayStatus = 'connected' | 'disconnected' | 'reconnecting';
|
||||
|
||||
type GatewayResponseFrame = {
|
||||
type?: string;
|
||||
id?: string;
|
||||
ok?: boolean;
|
||||
payload?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
type GatewayEventFrame = {
|
||||
type?: string;
|
||||
event?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMessageRole(value: unknown): RawMessage['role'] {
|
||||
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
||||
switch (normalized) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
case 'system':
|
||||
case 'tool_result':
|
||||
case 'toolresult':
|
||||
return normalized === 'tool_result' ? 'tool_result' : (normalized as RawMessage['role']);
|
||||
case 'tool':
|
||||
return 'toolresult';
|
||||
default:
|
||||
return 'assistant';
|
||||
}
|
||||
}
|
||||
|
||||
function toTextContentBlocks(text: string): ContentBlock[] {
|
||||
return text
|
||||
? [{ type: 'text', text }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function normalizeGatewayRawMessage(
|
||||
value: unknown,
|
||||
options?: { preferContentBlocks?: boolean },
|
||||
): RawMessage | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawContent = value.content;
|
||||
let content: RawMessage['content'] = '';
|
||||
|
||||
if (typeof rawContent === 'string') {
|
||||
content = options?.preferContentBlocks ? toTextContentBlocks(rawContent) : rawContent;
|
||||
} else if (Array.isArray(rawContent)) {
|
||||
content = rawContent as ContentBlock[];
|
||||
} else if (typeof value.text === 'string') {
|
||||
content = toTextContentBlocks(value.text);
|
||||
}
|
||||
|
||||
return {
|
||||
...(value as Partial<RawMessage>),
|
||||
role: normalizeMessageRole(value.role),
|
||||
content,
|
||||
timestamp: normalizeTimestamp(value.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextFromMessageContent(content: RawMessage['content'] | unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!isRecord(block)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof block.text === 'string') {
|
||||
return block.text;
|
||||
}
|
||||
|
||||
if (typeof block.thinking === 'string') {
|
||||
return block.thinking;
|
||||
}
|
||||
|
||||
if (typeof block.content === 'string') {
|
||||
return block.content;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractTextFromRawMessage(message: RawMessage): string {
|
||||
return extractTextFromMessageContent(message.content);
|
||||
}
|
||||
|
||||
function extractTextFromGatewayPayload(payload: Record<string, unknown>): string {
|
||||
if (typeof payload.delta === 'string') {
|
||||
return payload.delta;
|
||||
}
|
||||
|
||||
if (typeof payload.text === 'string') {
|
||||
return payload.text;
|
||||
}
|
||||
|
||||
const normalizedMessage = normalizeGatewayRawMessage(payload.message);
|
||||
if (normalizedMessage) {
|
||||
return extractTextFromRawMessage(normalizedMessage);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildGatewayRpcError(error: unknown, fallback: string): Error {
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return new Error(error);
|
||||
}
|
||||
|
||||
if (isRecord(error) && typeof error.message === 'string' && error.message.trim()) {
|
||||
return new Error(error.message);
|
||||
}
|
||||
|
||||
return new Error(fallback);
|
||||
}
|
||||
|
||||
async function findAvailablePort(): Promise<number> {
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = createServer();
|
||||
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!port) {
|
||||
reject(new Error('Failed to allocate an available Gateway port'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class GatewayManager {
|
||||
private initialized = false;
|
||||
private status: 'connected' | 'disconnected' | 'reconnecting' = 'disconnected';
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private stopPromise: Promise<void> | null = null;
|
||||
private status: GatewayStatus = 'disconnected';
|
||||
private readonly mode = 'openclaw' as const;
|
||||
private readonly processOwner = new OpenClawProcessOwner();
|
||||
private readonly pendingRequests = new Map<string, PendingGatewayRequest>();
|
||||
private readonly deltaSnapshots = new Map<string, string>();
|
||||
private gatewayToken = randomUUID();
|
||||
private socket: WebSocket | null = null;
|
||||
private child: Electron.UtilityProcess | null = null;
|
||||
private port: number | null = null;
|
||||
private exitCode: number | null = null;
|
||||
private lastError?: string;
|
||||
private stopping = false;
|
||||
private deviceIdentity: DeviceIdentity | null = null;
|
||||
|
||||
private setStatus(status: 'connected' | 'disconnected' | 'reconnecting'): void {
|
||||
private setStatus(status: GatewayStatus): void {
|
||||
this.status = status;
|
||||
updateTrayStatus(status);
|
||||
this.broadcast({ type: 'gateway:status', status });
|
||||
}
|
||||
|
||||
private async initDeviceIdentity(): Promise<void> {
|
||||
if (this.deviceIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const identityPath = join(getUserDataDir(), 'openclaw-device-identity.json');
|
||||
this.deviceIdentity = await loadOrCreateDeviceIdentity(identityPath);
|
||||
logManager.info('OpenClaw Gateway device identity loaded', {
|
||||
deviceId: this.deviceIdentity.deviceId,
|
||||
});
|
||||
} catch (error) {
|
||||
logManager.warn('Failed to load OpenClaw device identity; scopes may be limited:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async terminateChild(child: Electron.UtilityProcess): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
child.once('exit', () => {
|
||||
finish();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
finish();
|
||||
}, 1500);
|
||||
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async disposeTransport(reason: string): Promise<void> {
|
||||
const socket = this.socket;
|
||||
this.socket = null;
|
||||
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (error) {
|
||||
logManager.warn(`Failed to close OpenClaw Gateway socket during ${reason}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
this.port = null;
|
||||
if (child && this.exitCode === null) {
|
||||
this.exitCode = -1;
|
||||
} else if (!child) {
|
||||
this.exitCode = null;
|
||||
}
|
||||
this.deltaSnapshots.clear();
|
||||
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(`Gateway request cancelled: ${reason}`),
|
||||
);
|
||||
|
||||
if (child) {
|
||||
await this.terminateChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
private bindProcessLifecycle(child: Electron.UtilityProcess): void {
|
||||
child.on('exit', (code) => {
|
||||
if (this.child !== child) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.exitCode = code ?? -1;
|
||||
this.child = null;
|
||||
this.port = null;
|
||||
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastError = `OpenClaw Gateway exited unexpectedly (code=${code ?? 'unknown'})`;
|
||||
this.socket = null;
|
||||
this.deltaSnapshots.clear();
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(this.lastError),
|
||||
);
|
||||
this.setStatus('disconnected');
|
||||
logManager.warn(this.lastError);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
this.lastError = toErrorMessage(error);
|
||||
logManager.error('OpenClaw Gateway process error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
private handleGatewaySocketClosed(socket: WebSocket, code: number): void {
|
||||
if (this.socket !== socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket = null;
|
||||
this.deltaSnapshots.clear();
|
||||
this.lastError = `OpenClaw Gateway socket closed (code=${code})`;
|
||||
clearPendingGatewayRequests(
|
||||
this.pendingRequests,
|
||||
new Error(this.lastError),
|
||||
);
|
||||
this.setStatus('disconnected');
|
||||
logManager.warn(this.lastError);
|
||||
}
|
||||
|
||||
private handleGatewayFrame(frame: unknown): void {
|
||||
if (!isRecord(frame)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && typeof frame.id === 'string') {
|
||||
const response = frame as GatewayResponseFrame;
|
||||
if (response.ok === false) {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
response.id!,
|
||||
buildGatewayRpcError(response.error, `Gateway RPC failed: ${response.id}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolvePendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
response.id!,
|
||||
response.payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'event' && typeof frame.event === 'string') {
|
||||
this.handleGatewayEvent(frame as GatewayEventFrame);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGatewayEvent(event: GatewayEventFrame): void {
|
||||
switch (event.event) {
|
||||
case 'chat':
|
||||
if (isRecord(event.payload)) {
|
||||
this.handleChatEvent(event.payload);
|
||||
}
|
||||
break;
|
||||
case 'gateway.ready':
|
||||
logManager.info('OpenClaw Gateway reported ready');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatEvent(payload: Record<string, unknown>): void {
|
||||
const sessionKey = typeof payload.sessionKey === 'string'
|
||||
? normalizeAgentSessionKey(payload.sessionKey)
|
||||
: '';
|
||||
const runId = typeof payload.runId === 'string' ? payload.runId : '';
|
||||
const state = typeof payload.state === 'string' ? payload.state : '';
|
||||
|
||||
if (!sessionKey || !runId || !state) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case 'delta': {
|
||||
const nextSnapshot = extractTextFromGatewayPayload(payload);
|
||||
if (!nextSnapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSnapshot = this.deltaSnapshots.get(runId) ?? '';
|
||||
const delta = nextSnapshot.startsWith(previousSnapshot)
|
||||
? nextSnapshot.slice(previousSnapshot.length)
|
||||
: nextSnapshot;
|
||||
|
||||
this.deltaSnapshots.set(runId, nextSnapshot);
|
||||
|
||||
if (delta) {
|
||||
this.broadcast({
|
||||
type: 'chat:delta',
|
||||
sessionKey,
|
||||
runId,
|
||||
delta,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'final': {
|
||||
const snapshotText = this.deltaSnapshots.get(runId) ?? '';
|
||||
this.deltaSnapshots.delete(runId);
|
||||
|
||||
const message = normalizeGatewayRawMessage(payload.message, {
|
||||
preferContentBlocks: true,
|
||||
}) ?? (
|
||||
snapshotText
|
||||
? {
|
||||
role: 'assistant',
|
||||
content: toTextContentBlocks(snapshotText),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.broadcast({
|
||||
type: 'chat:final',
|
||||
sessionKey,
|
||||
runId,
|
||||
message,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
this.deltaSnapshots.delete(runId);
|
||||
this.broadcast({
|
||||
type: 'chat:error',
|
||||
sessionKey,
|
||||
runId,
|
||||
error: typeof payload.errorMessage === 'string'
|
||||
? payload.errorMessage
|
||||
: 'Gateway chat error',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'aborted': {
|
||||
this.deltaSnapshots.delete(runId);
|
||||
this.broadcast({
|
||||
type: 'chat:aborted',
|
||||
sessionKey,
|
||||
runId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async rpcGateway(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number | null },
|
||||
): Promise<unknown> {
|
||||
await this.start();
|
||||
|
||||
const socket = this.socket;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('OpenClaw Gateway socket is not connected');
|
||||
}
|
||||
|
||||
const requestId = `${method}-${randomUUID()}`;
|
||||
const timeoutMs = options?.timeoutMs ?? 30_000;
|
||||
|
||||
return await new Promise<unknown>((resolve, reject) => {
|
||||
const timeout = timeoutMs === null
|
||||
? null
|
||||
: setTimeout(() => {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
requestId,
|
||||
new Error(`Gateway RPC timed out: ${method}`),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
try {
|
||||
socket.send(JSON.stringify({
|
||||
type: 'req',
|
||||
id: requestId,
|
||||
method,
|
||||
params,
|
||||
}));
|
||||
} catch (error) {
|
||||
rejectPendingGatewayRequest(
|
||||
this.pendingRequests,
|
||||
requestId,
|
||||
new Error(`Failed to send Gateway RPC ${method}: ${toErrorMessage(error)}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
this.initialized = true;
|
||||
logManager.info('GatewayManager initialized');
|
||||
this.setStatus('connected');
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.initPromise) {
|
||||
return await this.initPromise;
|
||||
}
|
||||
|
||||
this.initPromise = (async () => {
|
||||
this.initialized = true;
|
||||
logManager.info('GatewayManager initialized in OpenClaw mode');
|
||||
|
||||
const autoStart = Boolean(configManager.get(CONFIG_KEYS.GATEWAY_AUTO_START));
|
||||
if (!autoStart) {
|
||||
this.setStatus('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.start();
|
||||
} catch (error) {
|
||||
this.lastError = toErrorMessage(error);
|
||||
this.setStatus('disconnected');
|
||||
logManager.error('Failed to auto-start OpenClaw Gateway:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.initPromise;
|
||||
} finally {
|
||||
this.initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.init();
|
||||
if (this.status === 'connected' && this.socket?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.startPromise) {
|
||||
return await this.startPromise;
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
this.startPromise = (async () => {
|
||||
if (this.stopPromise) {
|
||||
await this.stopPromise;
|
||||
}
|
||||
|
||||
this.stopping = false;
|
||||
this.lastError = undefined;
|
||||
this.setStatus('reconnecting');
|
||||
|
||||
try {
|
||||
await this.initDeviceIdentity();
|
||||
await this.processOwner.prepare();
|
||||
const runtimeStatus = this.processOwner.getStatus();
|
||||
if (!runtimeStatus.entryExists) {
|
||||
throw new Error(runtimeStatus.lastError || `OpenClaw entry not found at ${runtimeStatus.runtimePaths.entryPath}`);
|
||||
}
|
||||
|
||||
await this.disposeTransport('starting OpenClaw Gateway');
|
||||
|
||||
this.port = await findAvailablePort();
|
||||
this.exitCode = null;
|
||||
this.gatewayToken = randomUUID();
|
||||
|
||||
const child = await launchGatewayProcess({
|
||||
port: this.port,
|
||||
token: this.gatewayToken,
|
||||
openclawDir: runtimeStatus.runtimePaths.resolvedDir,
|
||||
entryScript: runtimeStatus.runtimePaths.entryPath,
|
||||
});
|
||||
|
||||
this.child = child;
|
||||
this.bindProcessLifecycle(child);
|
||||
|
||||
await waitForGatewayReady({
|
||||
port: this.port,
|
||||
getProcessExitCode: () => this.exitCode,
|
||||
});
|
||||
|
||||
this.socket = await connectGatewaySocket({
|
||||
port: this.port,
|
||||
token: this.gatewayToken,
|
||||
deviceIdentity: this.deviceIdentity,
|
||||
platform: process.platform,
|
||||
onMessage: (message) => this.handleGatewayFrame(message),
|
||||
onCloseAfterHandshake: (socket, code) => this.handleGatewaySocketClosed(socket, code),
|
||||
});
|
||||
|
||||
this.lastError = undefined;
|
||||
this.setStatus('connected');
|
||||
logManager.info('OpenClaw Gateway connected', {
|
||||
port: this.port,
|
||||
pid: this.child?.pid,
|
||||
});
|
||||
} catch (error) {
|
||||
this.lastError = toErrorMessage(error);
|
||||
await this.disposeTransport('failed OpenClaw Gateway start');
|
||||
this.setStatus('disconnected');
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.startPromise;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('disconnected');
|
||||
if (this.stopPromise) {
|
||||
return await this.stopPromise;
|
||||
}
|
||||
|
||||
this.stopPromise = (async () => {
|
||||
this.stopping = true;
|
||||
await this.disposeTransport('stopping OpenClaw Gateway');
|
||||
this.setStatus('disconnected');
|
||||
this.stopping = false;
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.stopPromise;
|
||||
} finally {
|
||||
this.stopPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async restart(options?: RuntimeChangeBroadcast): Promise<void> {
|
||||
this.initialized = false;
|
||||
this.setStatus('reconnecting');
|
||||
await this.init();
|
||||
await this.stop();
|
||||
await this.start();
|
||||
|
||||
if (options) {
|
||||
this.notifyRuntimeChanged(options);
|
||||
}
|
||||
}
|
||||
|
||||
getStatus(): {
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
status: GatewayStatus;
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
mode: 'openclaw';
|
||||
port: number | null;
|
||||
pid: number | null;
|
||||
lastError?: string;
|
||||
runtime: ReturnType<OpenClawProcessOwner['getStatus']>;
|
||||
} {
|
||||
return {
|
||||
status: this.status,
|
||||
initialized: this.initialized,
|
||||
mode: 'in-process',
|
||||
mode: this.mode,
|
||||
port: this.port,
|
||||
pid: this.child?.pid ?? null,
|
||||
lastError: this.lastError,
|
||||
runtime: this.processOwner.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{
|
||||
ok: boolean;
|
||||
status: 'connected' | 'disconnected' | 'reconnecting';
|
||||
status: GatewayStatus;
|
||||
initialized: boolean;
|
||||
mode: 'in-process';
|
||||
mode: 'openclaw';
|
||||
port: number | null;
|
||||
pid: number | null;
|
||||
lastError?: string;
|
||||
runtime: ReturnType<OpenClawProcessOwner['getStatus']>;
|
||||
}> {
|
||||
const status = this.getStatus();
|
||||
|
||||
return {
|
||||
ok: this.initialized && this.status === 'connected',
|
||||
...this.getStatus(),
|
||||
ok: status.initialized && status.status === 'connected' && this.socket?.readyState === WebSocket.OPEN,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,19 +734,76 @@ class GatewayManager {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
logManager.info(`Gateway RPC: ${method}`, params);
|
||||
|
||||
switch (method) {
|
||||
case 'chat.send':
|
||||
return chatHandlers.handleChatSend(params, (event) => this.broadcast(event));
|
||||
case 'chat.history':
|
||||
return chatHandlers.handleChatHistory(params);
|
||||
case 'chat.abort':
|
||||
return chatHandlers.handleChatAbort(params, (event) => this.broadcast(event));
|
||||
case 'session.list':
|
||||
return chatHandlers.handleSessionList();
|
||||
case 'session.delete':
|
||||
return chatHandlers.handleSessionDelete(params);
|
||||
case 'chat.send': {
|
||||
const request = params as GatewayRpcParams['chat.send'];
|
||||
const sessionKey = normalizeAgentSessionKey(request.sessionKey);
|
||||
const messageText = extractTextFromRawMessage(request.message);
|
||||
const response = await this.rpcGateway('chat.send', {
|
||||
sessionKey,
|
||||
message: messageText,
|
||||
deliver: false,
|
||||
idempotencyKey: request.message.id || randomUUID(),
|
||||
}, { timeoutMs: 30_000 });
|
||||
|
||||
const runId = (
|
||||
isRecord(response) &&
|
||||
typeof response.runId === 'string' &&
|
||||
response.runId.trim()
|
||||
)
|
||||
? response.runId
|
||||
: '';
|
||||
|
||||
if (!runId) {
|
||||
throw new Error('OpenClaw Gateway chat.send did not return a runId');
|
||||
}
|
||||
|
||||
return { runId };
|
||||
}
|
||||
case 'chat.history': {
|
||||
const request = params as GatewayRpcParams['chat.history'];
|
||||
const response = await this.rpcGateway('chat.history', {
|
||||
sessionKey: normalizeAgentSessionKey(request.sessionKey),
|
||||
limit: request.limit ?? 50,
|
||||
}, { timeoutMs: 15_000 });
|
||||
|
||||
if (!isRecord(response) || !Array.isArray(response.messages)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.messages
|
||||
.map((message) => normalizeGatewayRawMessage(message))
|
||||
.filter((message): message is RawMessage => message !== null);
|
||||
}
|
||||
case 'chat.abort': {
|
||||
const request = params as GatewayRpcParams['chat.abort'];
|
||||
await this.rpcGateway('chat.abort', {
|
||||
sessionKey: normalizeAgentSessionKey(request.sessionKey),
|
||||
}, { timeoutMs: 10_000 });
|
||||
return;
|
||||
}
|
||||
case 'session.list': {
|
||||
const response = await this.rpcGateway('sessions.list', {}, { timeoutMs: 10_000 });
|
||||
if (!isRecord(response) || !Array.isArray(response.sessions)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.sessions
|
||||
.map((session) => (
|
||||
isRecord(session) && typeof session.key === 'string'
|
||||
? session.key
|
||||
: null
|
||||
))
|
||||
.filter((sessionKey): sessionKey is string => Boolean(sessionKey));
|
||||
}
|
||||
case 'session.delete': {
|
||||
const request = params as GatewayRpcParams['session.delete'];
|
||||
await this.rpcGateway('sessions.delete', {
|
||||
key: normalizeAgentSessionKey(request.sessionKey),
|
||||
deleteTranscript: true,
|
||||
}, { timeoutMs: 15_000 });
|
||||
return { success: true };
|
||||
}
|
||||
case 'provider.list':
|
||||
return providerHandlers.handleProviderList();
|
||||
case 'provider.getDefault':
|
||||
@@ -107,7 +819,7 @@ class GatewayManager {
|
||||
|
||||
broadcast(event: GatewayEvent): void {
|
||||
const mainWindow = BrowserWindow.getAllWindows().find(
|
||||
(win) => windowManager.getName(win) === 'main'
|
||||
(win) => windowManager.getName(win) === 'main',
|
||||
);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('gateway:event', event);
|
||||
@@ -132,16 +844,27 @@ class GatewayManager {
|
||||
}
|
||||
|
||||
reloadProviders(options?: RuntimeChangeBroadcast): void {
|
||||
logManager.info('GatewayManager reloading providers');
|
||||
// For now, providers are resolved on each chat.send call,
|
||||
// so no in-memory cache to invalidate. Future: notify active sessions.
|
||||
this.notifyRuntimeChanged({
|
||||
const runtimeChange: RuntimeChangeBroadcast = {
|
||||
topics: options?.topics ?? ['providers', 'models'],
|
||||
reason: options?.reason ?? 'providers:reload',
|
||||
warnings: options?.warnings,
|
||||
channelType: options?.channelType,
|
||||
accountId: options?.accountId,
|
||||
});
|
||||
};
|
||||
|
||||
if (this.initialized && (this.status === 'connected' || this.status === 'reconnecting')) {
|
||||
void this.restart(runtimeChange).catch((error) => {
|
||||
const warning = `Gateway restart after provider reload failed: ${toErrorMessage(error)}`;
|
||||
logManager.error(warning, error);
|
||||
this.notifyRuntimeChanged({
|
||||
...runtimeChange,
|
||||
warnings: [...(runtimeChange.warnings ?? []), warning],
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.notifyRuntimeChanged(runtimeChange);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user