- Add EvaluationHook for post-execution agent evaluation - Add SkillAdaptationHook for dynamic skill adaptation - Add team/ directory with team coordination logic - Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config - Update RuntimeView, TraderView and RuntimeSettingsPanel UI - Add runtimeApi and websocket services - Add runtime_state.json to smoke_fullstack state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
7.3 KiB
JavaScript
283 lines
7.3 KiB
JavaScript
/**
|
|
* WebSocket Client with Dynamic Port Resolution
|
|
* Handles connection, reconnection, and heartbeat
|
|
* Fetches Gateway port from API before connecting
|
|
*/
|
|
|
|
import { WS_URL } from "../config/constants";
|
|
|
|
// Global port cache
|
|
let cachedGatewayPort = null;
|
|
let cachedWsUrl = null;
|
|
|
|
/**
|
|
* Fetch Gateway WebSocket port from API
|
|
*/
|
|
export async function fetchGatewayPort() {
|
|
try {
|
|
const response = await fetch('/api/runtime/gateway/port');
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
|
|
if (data.is_running && data.port) {
|
|
cachedGatewayPort = data.port;
|
|
cachedWsUrl = data.ws_url;
|
|
return { port: data.port, wsUrl: data.ws_url };
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.warn('[Gateway] Failed to fetch port:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cached or default WebSocket URL
|
|
*/
|
|
export function getWebSocketUrl() {
|
|
if (cachedWsUrl) {
|
|
return cachedWsUrl;
|
|
}
|
|
return WS_URL;
|
|
}
|
|
|
|
/**
|
|
* Clear cached port (call when Gateway restarts)
|
|
*/
|
|
export function clearGatewayCache() {
|
|
cachedGatewayPort = null;
|
|
cachedWsUrl = null;
|
|
}
|
|
|
|
export class ReadOnlyClient {
|
|
constructor(onEvent, { wsUrl = null, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
|
|
this.onEvent = onEvent;
|
|
this.wsUrl = wsUrl; // null = auto-resolve from API
|
|
this.baseReconnectDelay = reconnectDelay;
|
|
this.reconnectDelay = reconnectDelay;
|
|
this.maxReconnectDelay = 30000;
|
|
this.heartbeatInterval = heartbeatInterval;
|
|
this.ws = null;
|
|
this.shouldReconnect = false;
|
|
this.reconnectTimer = null;
|
|
this.heartbeatTimer = null;
|
|
this.reconnectAttempts = 0;
|
|
this.lastPongTime = 0;
|
|
this.isConnecting = false;
|
|
}
|
|
|
|
async connect() {
|
|
this.shouldReconnect = true;
|
|
this.reconnectAttempts = 0;
|
|
this.reconnectDelay = this.baseReconnectDelay;
|
|
await this._connect();
|
|
}
|
|
|
|
async _connect() {
|
|
if (!this.shouldReconnect || this.isConnecting) {
|
|
return;
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
|
|
// Resolve WebSocket URL if not set
|
|
let targetUrl = this.wsUrl;
|
|
if (!targetUrl) {
|
|
// Try to fetch from API first
|
|
const gatewayInfo = await fetchGatewayPort();
|
|
if (gatewayInfo) {
|
|
targetUrl = gatewayInfo.wsUrl;
|
|
console.log(`[WebSocket] Resolved Gateway port: ${gatewayInfo.port}`);
|
|
} else {
|
|
// Fallback to default
|
|
targetUrl = WS_URL;
|
|
console.log(`[WebSocket] Using default URL: ${targetUrl}`);
|
|
}
|
|
}
|
|
|
|
// Clear any existing connection
|
|
if (this.ws) {
|
|
this.ws.onopen = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.onerror = null;
|
|
this.ws.onclose = null;
|
|
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
|
this.ws.close();
|
|
}
|
|
this.ws = null;
|
|
}
|
|
|
|
try {
|
|
this.ws = new WebSocket(targetUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
this.reconnectAttempts = 0;
|
|
this.reconnectDelay = this.baseReconnectDelay;
|
|
this.lastPongTime = Date.now();
|
|
this._safeEmit({ type: "system", content: "已连接实时数据服务" });
|
|
console.log("WebSocket connected to", targetUrl);
|
|
this._startHeartbeat();
|
|
this.isConnecting = false;
|
|
};
|
|
|
|
this.ws.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
|
|
// Update pong time for any message (server is alive)
|
|
this.lastPongTime = Date.now();
|
|
|
|
if (msg.type === "pong") {
|
|
return;
|
|
}
|
|
|
|
console.log("[WebSocket] Message received:", msg.type || "unknown");
|
|
this._safeEmit(msg);
|
|
} catch (e) {
|
|
console.error("[WebSocket] Parse error:", e);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error("WebSocket error:", error);
|
|
this.isConnecting = false;
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
const code = event.code || "未知";
|
|
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
|
|
|
this._stopHeartbeat();
|
|
this.ws = null;
|
|
this.isConnecting = false;
|
|
|
|
// Always attempt reconnect if shouldReconnect is true
|
|
if (this.shouldReconnect) {
|
|
this.reconnectAttempts++;
|
|
// Exponential backoff with cap
|
|
this.reconnectDelay = Math.min(
|
|
this.baseReconnectDelay * Math.pow(1.5, this.reconnectAttempts),
|
|
this.maxReconnectDelay
|
|
);
|
|
|
|
this._safeEmit({
|
|
type: "system",
|
|
content: "正在尝试连接数据服务..."
|
|
});
|
|
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
}
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
console.log(`[WebSocket] Reconnect attempt ${this.reconnectAttempts}...`);
|
|
this._connect();
|
|
}, this.reconnectDelay);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error("[WebSocket] Connection error:", error);
|
|
this.isConnecting = false;
|
|
|
|
if (this.shouldReconnect) {
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this._connect();
|
|
}, this.reconnectDelay);
|
|
}
|
|
}
|
|
}
|
|
|
|
_safeEmit(msg) {
|
|
try {
|
|
this.onEvent(msg);
|
|
} catch (e) {
|
|
console.error("[WebSocket] Error in event handler:", e);
|
|
}
|
|
}
|
|
|
|
_startHeartbeat() {
|
|
this._stopHeartbeat();
|
|
this.lastPongTime = Date.now();
|
|
|
|
this.heartbeatTimer = setInterval(() => {
|
|
this._sendPing();
|
|
|
|
// Check for stale connection (no response in 60s)
|
|
const timeSinceLastPong = Date.now() - this.lastPongTime;
|
|
if (timeSinceLastPong > 60000 && this.ws) {
|
|
console.warn("[WebSocket] Connection appears stale, forcing reconnect");
|
|
this.ws.close();
|
|
}
|
|
}, this.heartbeatInterval);
|
|
}
|
|
|
|
_sendPing() {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
this.ws.send(JSON.stringify({ type: "ping" }));
|
|
} catch (e) {
|
|
console.error("Heartbeat send error:", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
_stopHeartbeat() {
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
}
|
|
|
|
send(message) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
|
this.ws.send(messageStr);
|
|
return true;
|
|
} catch (e) {
|
|
console.error("Send error:", e);
|
|
return false;
|
|
}
|
|
} else {
|
|
console.warn("WebSocket is not connected, cannot send message");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
this.shouldReconnect = false;
|
|
this._stopHeartbeat();
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
if (this.ws) {
|
|
this.ws.onopen = null;
|
|
this.ws.onmessage = null;
|
|
this.ws.onerror = null;
|
|
this.ws.onclose = null;
|
|
try {
|
|
this.ws.close();
|
|
} catch (e) {
|
|
console.error("Close error:", e);
|
|
}
|
|
}
|
|
this.ws = null;
|
|
this.isConnecting = false;
|
|
}
|
|
|
|
/**
|
|
* Reconnect with new port (call after Gateway restart)
|
|
*/
|
|
async reconnectWithNewPort() {
|
|
console.log("[WebSocket] Reconnecting with new port...");
|
|
clearGatewayCache();
|
|
this.disconnect();
|
|
this.shouldReconnect = true;
|
|
await this.connect();
|
|
}
|
|
}
|