cc工作前备份

This commit is contained in:
iven
2026-03-12 00:23:42 +08:00
parent f75a2b798b
commit ef849c62ab
98 changed files with 12110 additions and 568 deletions

13
src/gateway/index.ts Normal file
View File

@@ -0,0 +1,13 @@
export { GatewayManager } from './manager';
export type { GatewayManagerOptions, GatewayInfo, GatewayStatus } from './manager';
export { GatewayWsClient } from './ws-client';
export type {
ConnectParams,
GatewayRequest,
GatewayResponse,
GatewayEvent,
GatewayFrame,
AgentStreamEvent,
WsClientOptions,
ConnectionState,
} from './ws-client';

328
src/gateway/manager.ts Normal file
View File

@@ -0,0 +1,328 @@
/**
* ZCLAW Gateway Manager
*
* Manages the OpenClaw Gateway subprocess lifecycle:
* - Start/stop Gateway daemon
* - Health checking
* - Auto-restart on crash
* - Configuration management
*/
import { spawn, ChildProcess, execSync } from 'child_process';
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs';
export type GatewayStatus = 'stopped' | 'starting' | 'running' | 'error' | 'stopping';
export interface GatewayManagerOptions {
/** OpenClaw home directory (default: ~/.openclaw) */
home?: string;
/** Gateway port (default: 18789) */
port?: number;
/** Gateway host (default: 127.0.0.1) */
host?: string;
/** Auth token for Gateway connections */
token?: string;
/** Auto-restart on crash */
autoRestart?: boolean;
/** Max restart attempts */
maxRestarts?: number;
/** Health check interval in ms (default: 15000) */
healthCheckInterval?: number;
}
export interface GatewayInfo {
status: GatewayStatus;
pid?: number;
port: number;
host: string;
wsUrl: string;
version?: string;
uptime?: number;
error?: string;
}
export class GatewayManager extends EventEmitter {
private process: ChildProcess | null = null;
private status: GatewayStatus = 'stopped';
private startTime: number = 0;
private restartCount: number = 0;
private healthTimer: ReturnType<typeof setInterval> | null = null;
private options: Required<GatewayManagerOptions>;
private lastError: string | null = null;
constructor(opts: GatewayManagerOptions = {}) {
super();
this.options = {
home: opts.home || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw'),
port: opts.port || 18789,
host: opts.host || '127.0.0.1',
token: opts.token || '',
autoRestart: opts.autoRestart ?? true,
maxRestarts: opts.maxRestarts || 5,
healthCheckInterval: opts.healthCheckInterval || 15000,
};
}
/** Get current gateway info */
getInfo(): GatewayInfo {
return {
status: this.status,
pid: this.process?.pid,
port: this.options.port,
host: this.options.host,
wsUrl: `ws://${this.options.host}:${this.options.port}`,
version: this.getVersion(),
uptime: this.status === 'running' ? Date.now() - this.startTime : undefined,
error: this.lastError || undefined,
};
}
/** Check if OpenClaw is installed */
isInstalled(): boolean {
try {
execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/** Get OpenClaw version */
getVersion(): string | undefined {
try {
return execSync('openclaw --version', { encoding: 'utf-8', stdio: 'pipe' }).trim();
} catch {
return undefined;
}
}
/** Start the OpenClaw Gateway */
async start(): Promise<void> {
if (this.status === 'running' || this.status === 'starting') {
return;
}
if (!this.isInstalled()) {
this.setStatus('error');
this.lastError = 'OpenClaw is not installed';
throw new Error('OpenClaw is not installed. Run: npm install -g openclaw@latest');
}
this.setStatus('starting');
this.lastError = null;
try {
// Check if Gateway is already running (external instance)
const alreadyRunning = await this.checkHealth();
if (alreadyRunning) {
this.setStatus('running');
this.startTime = Date.now();
this.startHealthCheck();
this.emit('connected', { external: true });
return;
}
// Start Gateway as subprocess
const env: Record<string, string> = {
...process.env as Record<string, string>,
OPENCLAW_HOME: this.options.home,
};
if (this.options.token) {
env.OPENCLAW_GATEWAY_TOKEN = this.options.token;
}
this.process = spawn('openclaw', ['gateway'], {
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Capture stdout
this.process.stdout?.on('data', (data: Buffer) => {
const output = data.toString();
this.emit('log', { level: 'info', message: output.trim() });
// Detect when Gateway is ready
if (output.includes('Gateway listening') || output.includes('ready')) {
this.setStatus('running');
this.startTime = Date.now();
this.restartCount = 0;
this.startHealthCheck();
}
});
// Capture stderr
this.process.stderr?.on('data', (data: Buffer) => {
const output = data.toString();
this.emit('log', { level: 'error', message: output.trim() });
});
// Handle process exit
this.process.on('exit', (code, signal) => {
const wasRunning = this.status === 'running';
this.process = null;
this.stopHealthCheck();
if (this.status === 'stopping') {
this.setStatus('stopped');
return;
}
if (code !== 0 && wasRunning && this.options.autoRestart) {
this.restartCount++;
if (this.restartCount <= this.options.maxRestarts) {
this.lastError = `Gateway crashed (exit code: ${code}), restarting (${this.restartCount}/${this.options.maxRestarts})`;
this.emit('log', { level: 'warn', message: this.lastError });
setTimeout(() => this.start(), 2000);
return;
}
}
this.lastError = `Gateway exited with code ${code}, signal ${signal}`;
this.setStatus(code === 0 ? 'stopped' : 'error');
});
// Handle process error
this.process.on('error', (err) => {
this.lastError = err.message;
this.setStatus('error');
this.process = null;
});
// Wait for Gateway to be ready (timeout 30s)
await this.waitForReady(30000);
} catch (err: any) {
this.lastError = err.message;
this.setStatus('error');
throw err;
}
}
/** Stop the Gateway */
async stop(): Promise<void> {
if (this.status === 'stopped' || this.status === 'stopping') {
return;
}
this.setStatus('stopping');
this.stopHealthCheck();
if (this.process) {
// Send SIGTERM for graceful shutdown
this.process.kill('SIGTERM');
// Wait up to 10s for graceful exit
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
if (this.process) {
this.process.kill('SIGKILL');
}
resolve();
}, 10000);
if (this.process) {
this.process.once('exit', () => {
clearTimeout(timeout);
resolve();
});
} else {
clearTimeout(timeout);
resolve();
}
});
}
this.process = null;
this.setStatus('stopped');
}
/** Restart the Gateway */
async restart(): Promise<void> {
await this.stop();
await this.start();
}
/** Check Gateway health via HTTP */
async checkHealth(): Promise<boolean> {
try {
const url = `http://${this.options.host}:${this.options.port}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
return response.ok || response.status === 200 || response.status === 101;
} catch {
return false;
}
}
/** Wait for Gateway to become ready */
private waitForReady(timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const start = Date.now();
const check = async () => {
if (this.status === 'running') {
resolve();
return;
}
if (this.status === 'error') {
reject(new Error(this.lastError || 'Gateway failed to start'));
return;
}
if (Date.now() - start > timeoutMs) {
// Try health check as last resort
const healthy = await this.checkHealth();
if (healthy) {
this.setStatus('running');
this.startTime = Date.now();
this.startHealthCheck();
resolve();
return;
}
reject(new Error('Gateway startup timed out'));
return;
}
setTimeout(check, 1000);
};
check();
});
}
/** Start periodic health checks */
private startHealthCheck() {
this.stopHealthCheck();
this.healthTimer = setInterval(async () => {
const healthy = await this.checkHealth();
if (!healthy && this.status === 'running') {
this.emit('log', { level: 'warn', message: 'Health check failed' });
// Don't immediately mark as error — may be transient
}
}, this.options.healthCheckInterval);
}
/** Stop health checks */
private stopHealthCheck() {
if (this.healthTimer) {
clearInterval(this.healthTimer);
this.healthTimer = null;
}
}
/** Update status and emit event */
private setStatus(status: GatewayStatus) {
const prev = this.status;
this.status = status;
if (prev !== status) {
this.emit('status', { status, previous: prev });
}
}
}

443
src/gateway/ws-client.ts Normal file
View File

@@ -0,0 +1,443 @@
/**
* ZCLAW WebSocket Client
*
* Typed WebSocket client for OpenClaw Gateway protocol.
* Handles:
* - Connection + handshake (connect challenge/response)
* - Request/response pattern (with timeout)
* - Event subscription
* - Auto-reconnect
* - Agent streaming
*/
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import * as crypto from 'crypto';
// === Protocol Types ===
export interface ConnectParams {
minProtocol?: number;
maxProtocol?: number;
client: {
id: string;
version: string;
platform: string;
mode: 'operator' | 'node';
};
role: 'operator' | 'node';
scopes: string[];
auth?: { token?: string };
locale?: string;
userAgent?: string;
device?: {
id: string;
publicKey?: string;
signature?: string;
signedAt?: number;
nonce?: string;
};
}
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, any>;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: any;
error?: any;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: any;
seq?: number;
stateVersion?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent;
// Agent stream events
export interface AgentStreamEvent {
stream: 'assistant' | 'tool' | 'lifecycle';
delta?: string;
content?: string;
tool?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
}
export interface WsClientOptions {
/** Gateway WebSocket URL (default: ws://127.0.0.1:18789) */
url?: string;
/** Auth token */
token?: string;
/** Client identifier */
clientId?: string;
/** Client version */
clientVersion?: string;
/** Auto-reconnect on disconnect */
autoReconnect?: boolean;
/** Reconnect interval ms */
reconnectInterval?: number;
/** Max reconnect attempts (0 = infinite) */
maxReconnectAttempts?: number;
/** Request timeout ms */
requestTimeout?: number;
}
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
export class GatewayWsClient extends EventEmitter {
private ws: WebSocket | null = null;
private state: ConnectionState = 'disconnected';
private requestId: number = 0;
private pendingRequests = new Map<string, {
resolve: (value: any) => void;
reject: (reason: any) => void;
timer: ReturnType<typeof setTimeout>;
}>();
private reconnectAttempts: number = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private deviceId: string;
private options: Required<WsClientOptions>;
constructor(opts: WsClientOptions = {}) {
super();
this.options = {
url: opts.url || 'ws://127.0.0.1:18789',
token: opts.token || '',
clientId: opts.clientId || 'zclaw-tauri',
clientVersion: opts.clientVersion || '0.1.0',
autoReconnect: opts.autoReconnect ?? true,
reconnectInterval: opts.reconnectInterval || 3000,
maxReconnectAttempts: opts.maxReconnectAttempts || 0,
requestTimeout: opts.requestTimeout || 30000,
};
this.deviceId = crypto.randomBytes(16).toString('hex');
}
/** Current connection state */
getState(): ConnectionState {
return this.state;
}
/** Connect to Gateway */
async connect(): Promise<void> {
if (this.state === 'connected' || this.state === 'connecting') {
return;
}
this.setState('connecting');
return new Promise((resolve, reject) => {
try {
this.ws = new WebSocket(this.options.url);
this.ws.on('open', () => {
this.setState('handshaking');
// Wait for connect.challenge event
});
this.ws.on('message', (data: Buffer) => {
try {
const frame: GatewayFrame = JSON.parse(data.toString());
this.handleFrame(frame, resolve);
} catch (err: any) {
this.emit('error', new Error(`Failed to parse frame: ${err.message}`));
}
});
this.ws.on('close', (code: number, reason: Buffer) => {
const wasConnected = this.state === 'connected';
this.cleanup();
if (wasConnected && this.options.autoReconnect) {
this.scheduleReconnect();
}
this.emit('close', { code, reason: reason.toString() });
});
this.ws.on('error', (err: Error) => {
if (this.state === 'connecting') {
this.cleanup();
reject(err);
}
this.emit('error', err);
});
} catch (err) {
this.cleanup();
reject(err);
}
});
}
/** Disconnect from Gateway */
disconnect() {
this.cancelReconnect();
this.options.autoReconnect = false;
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
}
this.cleanup();
}
/** Send a request and wait for response */
async request(method: string, params?: Record<string, any>): Promise<any> {
if (this.state !== 'connected') {
throw new Error(`Cannot send request in state: ${this.state}`);
}
const id = `req_${++this.requestId}`;
const frame: GatewayRequest = { type: 'req', id, method, params: params || {} };
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRequests.delete(id);
reject(new Error(`Request ${method} timed out after ${this.options.requestTimeout}ms`));
}, this.options.requestTimeout);
this.pendingRequests.set(id, { resolve, reject, timer });
this.send(frame);
});
}
/** Send agent message (trigger agent loop) */
async sendAgentMessage(message: string, opts?: {
sessionKey?: string;
model?: string;
agentId?: string;
}): Promise<{ runId: string; acceptedAt: string }> {
return this.request('agent', {
message,
sessionKey: opts?.sessionKey,
model: opts?.model,
agentId: opts?.agentId,
});
}
/** Wait for agent run to complete */
async waitForAgent(runId: string): Promise<{ status: string; startedAt: string; endedAt: string; error?: string }> {
return this.request('agent.wait', { runId });
}
/** Send message through IM channel */
async sendChannelMessage(channel: string, chatId: string, text: string): Promise<any> {
return this.request('send', { channel, chatId, text });
}
/** Get Gateway health */
async getHealth(): Promise<any> {
return this.request('health');
}
/** Get Gateway status */
async getStatus(): Promise<any> {
return this.request('status');
}
// === ZCLAW Custom RPC Methods ===
async listClones(): Promise<any> {
return this.request('zclaw.clones.list');
}
async createClone(opts: { name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string }): Promise<any> {
return this.request('zclaw.clones.create', opts);
}
async getUsageStats(): Promise<any> {
return this.request('zclaw.stats.usage');
}
async getSessionStats(): Promise<any> {
return this.request('zclaw.stats.sessions');
}
async getWorkspaceInfo(): Promise<any> {
return this.request('zclaw.workspace.info');
}
async getPluginStatus(): Promise<any> {
return this.request('zclaw.plugins.status');
}
async getQuickConfig(): Promise<any> {
return this.request('zclaw.config.quick', { get: true });
}
async saveQuickConfig(config: Record<string, any>): Promise<any> {
return this.request('zclaw.config.quick', config);
}
// === Internal ===
private handleFrame(frame: GatewayFrame, connectResolve?: (value: void) => void) {
switch (frame.type) {
case 'event':
this.handleEvent(frame as GatewayEvent, connectResolve);
break;
case 'res':
this.handleResponse(frame as GatewayResponse);
break;
default:
// Ignore unexpected frame types
break;
}
}
private handleEvent(event: GatewayEvent, connectResolve?: (value: void) => void) {
if (event.event === 'connect.challenge' && this.state === 'handshaking') {
// Respond to challenge with connect request
this.performHandshake(event.payload?.nonce, connectResolve);
return;
}
// Emit typed events
this.emit('event', event);
this.emit(`event:${event.event}`, event.payload);
// Specific agent stream events
if (event.event === 'agent') {
this.emit('agent:stream', event.payload as AgentStreamEvent);
}
}
private performHandshake(nonce: string, connectResolve?: (value: void) => void) {
const platform = process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux';
const connectReq: GatewayRequest = {
type: 'req',
id: `connect_${Date.now()}`,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.options.clientId,
version: this.options.clientVersion,
platform,
mode: 'operator',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
auth: this.options.token ? { token: this.options.token } : {},
locale: 'zh-CN',
userAgent: `zclaw-tauri/${this.options.clientVersion}`,
device: {
id: this.deviceId,
},
},
};
// Listen for the connect response
const handler = (data: Buffer) => {
try {
const frame = JSON.parse(data.toString());
if (frame.type === 'res' && frame.id === connectReq.id) {
this.ws?.removeListener('message', handler);
if (frame.ok) {
this.setState('connected');
this.reconnectAttempts = 0;
this.emit('connected', frame.payload);
connectResolve?.();
} else {
const err = new Error(`Handshake failed: ${JSON.stringify(frame.error)}`);
this.emit('error', err);
this.cleanup();
}
}
} catch { /* ignore parse errors during handshake */ }
};
this.ws?.on('message', handler);
this.send(connectReq);
}
private handleResponse(res: GatewayResponse) {
const pending = this.pendingRequests.get(res.id);
if (pending) {
clearTimeout(pending.timer);
this.pendingRequests.delete(res.id);
if (res.ok) {
pending.resolve(res.payload);
} else {
pending.reject(new Error(JSON.stringify(res.error)));
}
}
}
private send(frame: GatewayFrame) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(frame));
}
}
private setState(state: ConnectionState) {
const prev = this.state;
this.state = state;
if (prev !== state) {
this.emit('state', { state, previous: prev });
}
}
private cleanup() {
// Clear all pending requests
for (const [id, pending] of this.pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new Error('Connection closed'));
}
this.pendingRequests.clear();
if (this.ws) {
this.ws.removeAllListeners();
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
try { this.ws.close(); } catch { /* ignore */ }
}
this.ws = null;
}
this.setState('disconnected');
}
private scheduleReconnect() {
if (this.options.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.options.maxReconnectAttempts) {
this.emit('reconnect:failed', { attempts: this.reconnectAttempts });
return;
}
this.reconnectAttempts++;
this.setState('reconnecting');
const delay = Math.min(
this.options.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
30000
);
this.reconnectTimer = setTimeout(async () => {
try {
await this.connect();
this.emit('reconnect:success', { attempts: this.reconnectAttempts });
} catch {
// Will trigger another reconnect via the close handler
}
}, delay);
}
private cancelReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}