feat: implement telemetry system for application usage tracking
- Added telemetry utility to capture application events and metrics. - Integrated PostHog for event tracking with distinct user identification. - Implemented telemetry initialization, event capturing, and shutdown procedures. feat: add UV environment setup for Python management - Created utilities to manage Python installation and configuration. - Implemented network optimization checks for Python installation mirrors. - Added functions to set up managed Python environments with error handling. feat: enhance host API communication with token management - Introduced host API token retrieval and management for secure requests. - Updated host API fetch functions to include token in headers. - Added support for creating event sources with authentication. test: add comprehensive tests for gateway protocol and startup helpers - Implemented unit tests for gateway protocol helpers, event dispatching, and state management. - Added tests for startup recovery strategies and process policies. - Ensured coverage for connection monitoring and restart governance logic.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { DeviceIdentity } from '@electron/utils/device-identity';
|
||||
import {
|
||||
buildDeviceAuthPayload,
|
||||
@@ -19,32 +20,8 @@ type GatewayProtocolFrame =
|
||||
}
|
||||
| null;
|
||||
|
||||
function isBlobLike(value: unknown): value is Blob {
|
||||
return typeof Blob !== 'undefined' && value instanceof Blob;
|
||||
}
|
||||
|
||||
async function dataToString(data: unknown): Promise<string> {
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString('utf-8');
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf-8');
|
||||
}
|
||||
|
||||
if (isBlobLike(data)) {
|
||||
return await data.text();
|
||||
}
|
||||
|
||||
return String(data ?? '');
|
||||
}
|
||||
|
||||
async function parseGatewayFrame(data: unknown): Promise<GatewayProtocolFrame> {
|
||||
const text = await dataToString(data);
|
||||
function parseGatewayFrame(data: WebSocket.RawData): GatewayProtocolFrame {
|
||||
const text = data.toString();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +108,7 @@ export async function probeGatewayReady(
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
ws.close();
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore probe close errors
|
||||
}
|
||||
@@ -142,24 +119,22 @@ export async function probeGatewayReady(
|
||||
resolveOnce(false);
|
||||
}, timeoutMs);
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const message = await parseGatewayFrame(event.data);
|
||||
if (message?.type === 'event' && message.event === 'connect.challenge') {
|
||||
resolveOnce(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed probe payloads
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = parseGatewayFrame(data);
|
||||
if (message?.type === 'event' && message.event === 'connect.challenge') {
|
||||
resolveOnce(true);
|
||||
}
|
||||
})();
|
||||
} catch {
|
||||
// ignore malformed probe payloads
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
ws.on('error', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
ws.on('close', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
});
|
||||
@@ -168,19 +143,22 @@ export async function probeGatewayReady(
|
||||
export async function waitForGatewayReady(options: {
|
||||
port: number;
|
||||
getProcessExitCode: () => number | null;
|
||||
retries?: number;
|
||||
timeoutMs?: number;
|
||||
intervalMs?: number;
|
||||
probeTimeoutMs?: number;
|
||||
}): Promise<void> {
|
||||
const retries = options.retries ?? 300;
|
||||
const timeoutMs = options.timeoutMs ?? (process.platform === 'win32' ? 180_000 : 90_000);
|
||||
const intervalMs = options.intervalMs ?? 200;
|
||||
const probeTimeoutMs = options.probeTimeoutMs ?? 1500;
|
||||
const startedAt = Date.now();
|
||||
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const exitCode = options.getProcessExitCode();
|
||||
if (exitCode !== null) {
|
||||
throw new Error(`OpenClaw Gateway exited before becoming ready (code=${exitCode})`);
|
||||
}
|
||||
|
||||
const ready = await probeGatewayReady(options.port, 1500);
|
||||
const ready = await probeGatewayReady(options.port, probeTimeoutMs);
|
||||
if (ready) {
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +166,7 @@ export async function waitForGatewayReady(options: {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
throw new Error(`OpenClaw Gateway failed to become ready on port ${options.port}`);
|
||||
throw new Error(`OpenClaw Gateway failed to become ready on port ${options.port} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
export async function connectGatewaySocket(options: {
|
||||
@@ -234,112 +212,105 @@ export async function connectGatewaySocket(options: {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
ws.terminate();
|
||||
} catch {
|
||||
// ignore terminate errors
|
||||
}
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
challengeTimer = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore close error
|
||||
}
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from OpenClaw Gateway'));
|
||||
}, challengeTimeoutMs);
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const message = await parseGatewayFrame(event.data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handshakeComplete && message.type === 'event' && message.event === 'connect.challenge') {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
|
||||
const nonce = (
|
||||
typeof message.payload === 'object' &&
|
||||
message.payload !== null &&
|
||||
'nonce' in message.payload &&
|
||||
typeof (message.payload as { nonce?: unknown }).nonce === 'string'
|
||||
)
|
||||
? (message.payload as { nonce: string }).nonce
|
||||
: '';
|
||||
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('OpenClaw Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildGatewayConnectFrame({
|
||||
challengeNonce: nonce,
|
||||
token: options.token,
|
||||
deviceIdentity: options.deviceIdentity,
|
||||
platform: options.platform,
|
||||
});
|
||||
connectId = payload.connectId;
|
||||
ws.send(JSON.stringify(payload.frame));
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
// ignore close error
|
||||
}
|
||||
rejectOnce(new Error('Timed out waiting for OpenClaw Gateway connect response'));
|
||||
}, connectTimeoutMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handshakeComplete && message.type === 'res' && message.id === connectId) {
|
||||
if (message.ok === false) {
|
||||
const errorMessage =
|
||||
typeof message.error === 'string'
|
||||
? message.error
|
||||
: (
|
||||
typeof message.error === 'object' &&
|
||||
message.error !== null &&
|
||||
'message' in message.error &&
|
||||
typeof (message.error as { message?: unknown }).message === 'string'
|
||||
)
|
||||
? (message.error as { message: string }).message
|
||||
: 'OpenClaw Gateway connect handshake failed';
|
||||
rejectOnce(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
handshakeComplete = true;
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handshakeComplete) {
|
||||
options.onMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(error);
|
||||
}
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = parseGatewayFrame(data);
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!handshakeComplete && message.type === 'event' && message.event === 'connect.challenge') {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
|
||||
const nonce = (
|
||||
typeof message.payload === 'object' &&
|
||||
message.payload !== null &&
|
||||
'nonce' in message.payload &&
|
||||
typeof (message.payload as { nonce?: unknown }).nonce === 'string'
|
||||
)
|
||||
? (message.payload as { nonce: string }).nonce
|
||||
: '';
|
||||
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('OpenClaw Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildGatewayConnectFrame({
|
||||
challengeNonce: nonce,
|
||||
token: options.token,
|
||||
deviceIdentity: options.deviceIdentity,
|
||||
platform: options.platform,
|
||||
});
|
||||
connectId = payload.connectId;
|
||||
ws.send(JSON.stringify(payload.frame));
|
||||
|
||||
handshakeTimer = setTimeout(() => {
|
||||
rejectOnce(new Error('Timed out waiting for OpenClaw Gateway connect response'));
|
||||
}, connectTimeoutMs);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!handshakeComplete && message.type === 'res' && message.id === connectId) {
|
||||
if (message.ok === false) {
|
||||
const errorMessage =
|
||||
typeof message.error === 'string'
|
||||
? message.error
|
||||
: (
|
||||
typeof message.error === 'object' &&
|
||||
message.error !== null &&
|
||||
'message' in message.error &&
|
||||
typeof (message.error as { message?: unknown }).message === 'string'
|
||||
)
|
||||
? (message.error as { message: string }).message
|
||||
: 'OpenClaw Gateway connect handshake failed';
|
||||
rejectOnce(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
handshakeComplete = true;
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
if (handshakeComplete) {
|
||||
options.onMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', (event) => {
|
||||
ws.on('close', (code) => {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error(`OpenClaw Gateway socket closed before handshake (code=${event.code})`));
|
||||
rejectOnce(new Error(`OpenClaw Gateway socket closed before handshake (code=${code})`));
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
options.onCloseAfterHandshake(ws, event.code);
|
||||
options.onCloseAfterHandshake(ws, code);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
ws.on('error', (error) => {
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error('OpenClaw Gateway socket connection failed'));
|
||||
rejectOnce(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user