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,53 +1,196 @@
|
||||
import { utilityProcess } from 'electron';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { existsSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import logManager from '@electron/service/logger';
|
||||
import { appendNodeRequireToNodeOptions } from '@electron/utils/paths';
|
||||
import type { GatewayLaunchContext } from './config-sync';
|
||||
import { resolveGatewayLaunchStrategy, type GatewayLaunchStrategy } from './launch-strategy';
|
||||
import type { GatewayProcessHandle } from './process-handle';
|
||||
|
||||
export interface OpenClawGatewayLaunchContext {
|
||||
port: number;
|
||||
token: string;
|
||||
openclawDir: string;
|
||||
entryScript: string;
|
||||
const GATEWAY_PRELOAD_SOURCE = `'use strict';
|
||||
(function () {
|
||||
if (process.platform !== 'win32') return;
|
||||
try {
|
||||
var cp = require('child_process');
|
||||
if (cp.__znAiGatewayPatched) return;
|
||||
cp.__znAiGatewayPatched = true;
|
||||
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function (method) {
|
||||
var original = cp[method];
|
||||
if (typeof original !== 'function') return;
|
||||
cp[method] = function () {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var optIdx = -1;
|
||||
for (var i = 1; i < args.length; i++) {
|
||||
var candidate = args[i];
|
||||
if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
|
||||
optIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optIdx >= 0) {
|
||||
args[optIdx].windowsHide = true;
|
||||
} else {
|
||||
var opts = { windowsHide: true };
|
||||
if (typeof args[args.length - 1] === 'function') {
|
||||
args.splice(args.length - 1, 0, opts);
|
||||
} else {
|
||||
args.push(opts);
|
||||
}
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
function ensureGatewayPreload(): string {
|
||||
const destination = path.join(app.getPath('userData'), 'gateway-preload.cjs');
|
||||
try {
|
||||
writeFileSync(destination, GATEWAY_PRELOAD_SOURCE, 'utf-8');
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
export async function launchGatewayProcess(
|
||||
context: OpenClawGatewayLaunchContext,
|
||||
): Promise<Electron.UtilityProcess> {
|
||||
const gatewayArgs = [
|
||||
'gateway',
|
||||
'--port',
|
||||
String(context.port),
|
||||
'--token',
|
||||
context.token,
|
||||
'--allow-unconfigured',
|
||||
];
|
||||
export interface LaunchGatewayProcessOptions {
|
||||
port: number;
|
||||
launchContext: GatewayLaunchContext;
|
||||
sanitizeSpawnArgs?: (args: string[]) => string[];
|
||||
onStdoutLine?: (line: string) => void;
|
||||
onStderrLine?: (line: string) => void;
|
||||
onSpawn?: (pid: number | undefined) => void;
|
||||
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
OPENCLAW_GATEWAY_TOKEN: context.token,
|
||||
OPENCLAW_SKIP_CHANNELS: '1',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
function emitProcessOutput(
|
||||
child: GatewayProcessHandle,
|
||||
stream: 'stdout' | 'stderr',
|
||||
onLine: ((line: string) => void) | undefined,
|
||||
): void {
|
||||
const readable = child[stream];
|
||||
if (!readable) {
|
||||
return;
|
||||
}
|
||||
|
||||
logManager.info('Starting OpenClaw Gateway process', {
|
||||
port: context.port,
|
||||
entryScript: context.entryScript,
|
||||
cwd: context.openclawDir,
|
||||
args: gatewayArgs,
|
||||
readable.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (onLine) {
|
||||
onLine(trimmed);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stream === 'stdout') {
|
||||
logManager.debug(`[OpenClaw stdout] ${trimmed}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logManager.warn(`[OpenClaw] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return await new Promise<Electron.UtilityProcess>((resolve, reject) => {
|
||||
const child = utilityProcess.fork(context.entryScript, gatewayArgs, {
|
||||
cwd: context.openclawDir,
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
serviceName: 'OpenClaw Gateway',
|
||||
});
|
||||
function buildGatewayRuntimeEnv(
|
||||
launchContext: GatewayLaunchContext,
|
||||
): Record<string, string | undefined> {
|
||||
const runtimeEnv = { ...launchContext.forkEnv };
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const preloadPath = ensureGatewayPreload();
|
||||
if (existsSync(preloadPath)) {
|
||||
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
|
||||
runtimeEnv.NODE_OPTIONS,
|
||||
preloadPath,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logManager.warn('Failed to prepare Gateway preload', error);
|
||||
}
|
||||
}
|
||||
|
||||
return runtimeEnv;
|
||||
}
|
||||
|
||||
function resolvePreferredLaunchStrategy(
|
||||
launchContext: GatewayLaunchContext,
|
||||
): GatewayLaunchStrategy {
|
||||
return resolveGatewayLaunchStrategy({
|
||||
platform: process.platform,
|
||||
mode: launchContext.mode,
|
||||
forced: process.env.ZN_AI_GATEWAY_LAUNCH_STRATEGY,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNodeRuntimeHost(options: {
|
||||
launchContext: GatewayLaunchContext;
|
||||
runtimeEnv: Record<string, string | undefined>;
|
||||
}): {
|
||||
command: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
hostKind: 'bundled-node' | 'electron-run-as-node';
|
||||
} {
|
||||
const bundledNodePath = process.platform === 'win32'
|
||||
? path.join(options.launchContext.binDir, 'node.exe')
|
||||
: '';
|
||||
|
||||
if (bundledNodePath && existsSync(bundledNodePath)) {
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...envWithoutElectronNode } = options.runtimeEnv;
|
||||
return {
|
||||
command: bundledNodePath,
|
||||
env: envWithoutElectronNode as NodeJS.ProcessEnv,
|
||||
hostKind: 'bundled-node',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: process.execPath,
|
||||
env: {
|
||||
...options.runtimeEnv,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
} as NodeJS.ProcessEnv,
|
||||
hostKind: 'electron-run-as-node',
|
||||
};
|
||||
}
|
||||
|
||||
async function launchWithUtilityProcess(options: {
|
||||
launchContext: GatewayLaunchContext;
|
||||
runtimeEnv: Record<string, string | undefined>;
|
||||
lastSpawnSummary: string;
|
||||
onStdoutLine?: (line: string) => void;
|
||||
onStderrLine?: (line: string) => void;
|
||||
onSpawn?: (pid: number | undefined) => void;
|
||||
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
||||
return await new Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }>((resolve, reject) => {
|
||||
const child = utilityProcess.fork(
|
||||
options.launchContext.entryScript,
|
||||
options.launchContext.gatewayArgs,
|
||||
{
|
||||
cwd: options.launchContext.openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: options.runtimeEnv as NodeJS.ProcessEnv,
|
||||
serviceName: 'OpenClaw Gateway',
|
||||
},
|
||||
) as GatewayProcessHandle;
|
||||
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(child);
|
||||
resolve({ child, lastSpawnSummary: options.lastSpawnSummary });
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
@@ -57,29 +200,135 @@ export async function launchGatewayProcess(
|
||||
};
|
||||
|
||||
child.once('spawn', () => {
|
||||
logManager.info('OpenClaw Gateway process spawned', { pid: child.pid });
|
||||
logManager.info('OpenClaw Gateway process spawned', {
|
||||
pid: child.pid,
|
||||
launchStrategy: 'utility-process',
|
||||
});
|
||||
options.onSpawn?.(child.pid);
|
||||
resolveOnce();
|
||||
});
|
||||
|
||||
child.once('error', (error) => {
|
||||
logManager.error('OpenClaw Gateway process spawn error:', error);
|
||||
options.onError?.(error);
|
||||
rejectOnce(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
options.onExit?.(child, code ?? null);
|
||||
if (!settled) {
|
||||
rejectOnce(new Error(`OpenClaw Gateway exited before spawn completed (code=${code ?? 'unknown'})`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
logManager.warn(`[OpenClaw] ${trimmed}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
emitProcessOutput(child, 'stdout', options.onStdoutLine);
|
||||
emitProcessOutput(child, 'stderr', options.onStderrLine);
|
||||
});
|
||||
}
|
||||
|
||||
async function launchWithNodeRuntime(options: {
|
||||
launchContext: GatewayLaunchContext;
|
||||
runtimeEnv: Record<string, string | undefined>;
|
||||
lastSpawnSummary: string;
|
||||
onStdoutLine?: (line: string) => void;
|
||||
onStderrLine?: (line: string) => void;
|
||||
onSpawn?: (pid: number | undefined) => void;
|
||||
onExit?: (child: GatewayProcessHandle, code: number | null) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
||||
const runtimeHost = resolveNodeRuntimeHost({
|
||||
launchContext: options.launchContext,
|
||||
runtimeEnv: options.runtimeEnv,
|
||||
});
|
||||
|
||||
return await new Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
runtimeHost.command,
|
||||
[options.launchContext.entryScript, ...options.launchContext.gatewayArgs],
|
||||
{
|
||||
cwd: options.launchContext.openclawDir,
|
||||
env: runtimeHost.env,
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
},
|
||||
) as GatewayProcessHandle;
|
||||
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ child, lastSpawnSummary: options.lastSpawnSummary });
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.once('spawn', () => {
|
||||
logManager.info('OpenClaw Gateway process spawned', {
|
||||
pid: child.pid,
|
||||
launchStrategy: 'node-runtime',
|
||||
runtimeHost: runtimeHost.hostKind,
|
||||
execPath: runtimeHost.command,
|
||||
});
|
||||
options.onSpawn?.(child.pid);
|
||||
resolveOnce();
|
||||
});
|
||||
|
||||
child.once('error', (error) => {
|
||||
logManager.error('OpenClaw Gateway process spawn error:', error);
|
||||
options.onError?.(error);
|
||||
rejectOnce(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code) => {
|
||||
options.onExit?.(child, code ?? null);
|
||||
if (!settled) {
|
||||
rejectOnce(new Error(`OpenClaw Gateway exited before spawn completed (code=${code ?? 'unknown'})`));
|
||||
}
|
||||
});
|
||||
|
||||
emitProcessOutput(child, 'stdout', options.onStdoutLine);
|
||||
emitProcessOutput(child, 'stderr', options.onStderrLine);
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchGatewayProcess(
|
||||
options: LaunchGatewayProcessOptions,
|
||||
): Promise<{ child: GatewayProcessHandle; lastSpawnSummary: string }> {
|
||||
const { launchContext } = options;
|
||||
const sanitizedArgs = options.sanitizeSpawnArgs?.(launchContext.gatewayArgs) ?? launchContext.gatewayArgs;
|
||||
const launchStrategy = resolvePreferredLaunchStrategy(launchContext);
|
||||
const lastSpawnSummary = `mode=${launchContext.mode}, launcher="${launchStrategy}", entry="${launchContext.entryScript}", args="${sanitizedArgs.join(' ')}", cwd="${launchContext.openclawDir}"`;
|
||||
const runtimeEnv = buildGatewayRuntimeEnv(launchContext);
|
||||
|
||||
logManager.info('Starting OpenClaw Gateway process', {
|
||||
port: options.port,
|
||||
entryScript: launchContext.entryScript,
|
||||
cwd: launchContext.openclawDir,
|
||||
args: sanitizedArgs,
|
||||
mode: launchContext.mode,
|
||||
bundledBin: launchContext.binPathExists,
|
||||
launchStrategy,
|
||||
});
|
||||
|
||||
const launchOptions = {
|
||||
launchContext,
|
||||
runtimeEnv,
|
||||
lastSpawnSummary,
|
||||
onStdoutLine: options.onStdoutLine,
|
||||
onStderrLine: options.onStderrLine,
|
||||
onSpawn: options.onSpawn,
|
||||
onExit: options.onExit,
|
||||
onError: options.onError,
|
||||
};
|
||||
|
||||
if (launchStrategy === 'node-runtime') {
|
||||
return await launchWithNodeRuntime(launchOptions);
|
||||
}
|
||||
|
||||
return await launchWithUtilityProcess(launchOptions);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user