mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 18:40:57 -07:00
feat(cli): add DevTools integration with gemini-cli-devtools (#18648)
This commit is contained in:
@@ -21,29 +21,6 @@ import WebSocket from 'ws';
|
||||
const ACTIVITY_ID_HEADER = 'x-activity-request-id';
|
||||
const MAX_BUFFER_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Parse a host:port string into its components.
|
||||
* Uses the URL constructor for robust handling of IPv4, IPv6, and hostnames.
|
||||
* Returns null for file paths or values without a valid port.
|
||||
*/
|
||||
function parseHostPort(value: string): { host: string; port: number } | null {
|
||||
if (value.startsWith('/') || value.startsWith('.')) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(`ws://${value}`);
|
||||
if (!url.port) return null;
|
||||
|
||||
const port = parseInt(url.port, 10);
|
||||
if (url.hostname && !isNaN(port) && port > 0 && port <= 65535) {
|
||||
return { host: url.hostname, port };
|
||||
}
|
||||
} catch {
|
||||
// Not a valid host:port
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface NetworkLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
@@ -494,12 +471,15 @@ function setupNetworkLogging(
|
||||
host: string,
|
||||
port: number,
|
||||
config: Config,
|
||||
onReconnectFailed?: () => void,
|
||||
) {
|
||||
const buffer: Array<Record<string, unknown>> = [];
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: NodeJS.Timeout | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_ATTEMPTS = 2;
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
@@ -507,6 +487,7 @@ function setupNetworkLogging(
|
||||
|
||||
ws.on('open', () => {
|
||||
debugLogger.debug(`WebSocket connected to ${host}:${port}`);
|
||||
reconnectAttempts = 0;
|
||||
// Register with CLI's session ID
|
||||
sendMessage({
|
||||
type: 'register',
|
||||
@@ -620,11 +601,20 @@ function setupNetworkLogging(
|
||||
const scheduleReconnect = () => {
|
||||
if (reconnectTimer) return;
|
||||
|
||||
reconnectAttempts++;
|
||||
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS && onReconnectFailed) {
|
||||
debugLogger.debug(
|
||||
`WebSocket reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts, promoting to server...`,
|
||||
);
|
||||
onReconnectFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
debugLogger.debug('Reconnecting WebSocket...');
|
||||
connect();
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
@@ -645,41 +635,65 @@ function setupNetworkLogging(
|
||||
});
|
||||
}
|
||||
|
||||
let bridgeAttached = false;
|
||||
|
||||
/**
|
||||
* Registers the activity logger if debug mode and interactive session are enabled.
|
||||
* Captures network and console logs to a session-specific JSONL file or sends to network.
|
||||
*
|
||||
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
|
||||
* - host:port format (e.g., "localhost:25417") → network mode (auto-enabled)
|
||||
* - file path (e.g., "/tmp/logs.jsonl") → file mode (immediate)
|
||||
* - not set → uses default file location in project temp logs dir
|
||||
*
|
||||
* @param config The CLI configuration
|
||||
* Bridge coreEvents to the ActivityLogger singleton (guarded — only once).
|
||||
*/
|
||||
export function registerActivityLogger(config: Config) {
|
||||
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
||||
const hostPort = target ? parseHostPort(target) : null;
|
||||
|
||||
// Network mode doesn't need storage; file mode does
|
||||
if (!hostPort && !config.storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capture = ActivityLogger.getInstance();
|
||||
capture.enable();
|
||||
|
||||
if (hostPort) {
|
||||
// Network mode: send logs via WebSocket
|
||||
setupNetworkLogging(capture, hostPort.host, hostPort.port, config);
|
||||
// Auto-enable network logging when target is explicitly configured
|
||||
capture.enableNetworkLogging();
|
||||
} else {
|
||||
// File mode: write to JSONL file
|
||||
setupFileLogging(capture, config, target);
|
||||
}
|
||||
|
||||
// Bridge CoreEvents to local capture
|
||||
function bridgeCoreEvents(capture: ActivityLogger) {
|
||||
if (bridgeAttached) return;
|
||||
bridgeAttached = true;
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload) => {
|
||||
capture.logConsole(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the activity logger with a specific transport mode.
|
||||
*
|
||||
* @param config CLI configuration
|
||||
* @param options Transport configuration: network (WebSocket) or file (JSONL)
|
||||
*/
|
||||
export function initActivityLogger(
|
||||
config: Config,
|
||||
options:
|
||||
| {
|
||||
mode: 'network';
|
||||
host: string;
|
||||
port: number;
|
||||
onReconnectFailed?: () => void;
|
||||
}
|
||||
| { mode: 'file'; filePath?: string },
|
||||
): void {
|
||||
const capture = ActivityLogger.getInstance();
|
||||
capture.enable();
|
||||
|
||||
if (options.mode === 'network') {
|
||||
setupNetworkLogging(
|
||||
capture,
|
||||
options.host,
|
||||
options.port,
|
||||
config,
|
||||
options.onReconnectFailed,
|
||||
);
|
||||
capture.enableNetworkLogging();
|
||||
} else {
|
||||
setupFileLogging(capture, config, options.filePath);
|
||||
}
|
||||
|
||||
bridgeCoreEvents(capture);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a network (WebSocket) transport to the existing ActivityLogger singleton.
|
||||
* Used for promotion re-entry without re-bridging coreEvents.
|
||||
*/
|
||||
export function addNetworkTransport(
|
||||
config: Config,
|
||||
host: string,
|
||||
port: number,
|
||||
onReconnectFailed?: () => void,
|
||||
): void {
|
||||
const capture = ActivityLogger.getInstance();
|
||||
setupNetworkLogging(capture, host, port, config, onReconnectFailed);
|
||||
}
|
||||
|
||||
303
packages/cli/src/utils/devtoolsService.test.ts
Normal file
303
packages/cli/src/utils/devtoolsService.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
// --- Mocks (hoisted) ---
|
||||
|
||||
const mockInitActivityLogger = vi.hoisted(() => vi.fn());
|
||||
const mockAddNetworkTransport = vi.hoisted(() => vi.fn());
|
||||
|
||||
type Listener = (...args: unknown[]) => void;
|
||||
|
||||
const { MockWebSocket } = vi.hoisted(() => {
|
||||
class MockWebSocket {
|
||||
close = vi.fn();
|
||||
url: string;
|
||||
static instances: MockWebSocket[] = [];
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
on(event: string, fn: Listener) {
|
||||
const fns = this.listeners.get(event) || [];
|
||||
fns.push(fn);
|
||||
this.listeners.set(event, fns);
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const fn of this.listeners.get(event) || []) {
|
||||
fn(...args);
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
this.emit('open');
|
||||
}
|
||||
|
||||
simulateError() {
|
||||
this.emit('error', new Error('ECONNREFUSED'));
|
||||
}
|
||||
}
|
||||
return { MockWebSocket };
|
||||
});
|
||||
|
||||
const mockDevToolsInstance = vi.hoisted(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
getPort: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./activityLogger.js', () => ({
|
||||
initActivityLogger: mockInitActivityLogger,
|
||||
addNetworkTransport: mockAddNetworkTransport,
|
||||
}));
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
default: MockWebSocket,
|
||||
}));
|
||||
|
||||
vi.mock('gemini-cli-devtools', () => ({
|
||||
DevTools: {
|
||||
getInstance: () => mockDevToolsInstance,
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Import under test (after mocks) ---
|
||||
import { registerActivityLogger, resetForTesting } from './devtoolsService.js';
|
||||
|
||||
function createMockConfig(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session'),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
storage: { getProjectTempLogsDir: vi.fn().mockReturnValue('/tmp/logs') },
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
describe('devtoolsService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
MockWebSocket.instances = [];
|
||||
resetForTesting();
|
||||
delete process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
||||
});
|
||||
|
||||
describe('registerActivityLogger', () => {
|
||||
it('connects to existing DevTools server when probe succeeds', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
// The probe WebSocket will succeed
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
// Wait for WebSocket to be created
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Simulate probe success
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417,
|
||||
onReconnectFailed: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('starts new DevTools server when probe fails', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
// Wait for probe WebSocket
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Simulate probe failure
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockDevToolsInstance.start).toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417,
|
||||
onReconnectFailed: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to file mode when target env var is set', async () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig();
|
||||
|
||||
await registerActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'file',
|
||||
filePath: '/tmp/test.jsonl',
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing in file mode when config.storage is missing', async () => {
|
||||
process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'] = '/tmp/test.jsonl';
|
||||
const config = createMockConfig({ storage: undefined });
|
||||
|
||||
await registerActivityLogger(config);
|
||||
|
||||
expect(mockInitActivityLogger).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to file logging when DevTools start fails', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockRejectedValue(
|
||||
new Error('MODULE_NOT_FOUND'),
|
||||
);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
// Wait for probe WebSocket
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
|
||||
// Probe fails → tries to start server → server start fails → file fallback
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(config, {
|
||||
mode: 'file',
|
||||
filePath: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOrJoinDevTools (via registerActivityLogger)', () => {
|
||||
it('stops own server and connects to existing when losing port race', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
// Server starts on a different port (lost the race)
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25418);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
// First: probe for existing server (fails)
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
// Second: after starting, probes the default port winner
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(2);
|
||||
});
|
||||
// Winner is alive
|
||||
MockWebSocket.instances[1].simulateOpen();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockDevToolsInstance.stop).toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({
|
||||
mode: 'network',
|
||||
host: '127.0.0.1',
|
||||
port: 25417, // connected to winner's port
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps own server when winner is not responding', async () => {
|
||||
const config = createMockConfig();
|
||||
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25418');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25418);
|
||||
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
// Probe for existing (fails)
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
// Probe the winner (also fails)
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(2);
|
||||
});
|
||||
MockWebSocket.instances[1].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(mockDevToolsInstance.stop).not.toHaveBeenCalled();
|
||||
expect(mockInitActivityLogger).toHaveBeenCalledWith(
|
||||
config,
|
||||
expect.objectContaining({
|
||||
mode: 'network',
|
||||
port: 25418, // kept own port
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePromotion (via onReconnectFailed)', () => {
|
||||
it('caps promotion attempts at MAX_PROMOTION_ATTEMPTS', async () => {
|
||||
const config = createMockConfig();
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
// First: set up the logger so we can grab onReconnectFailed
|
||||
const promise = registerActivityLogger(config);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(MockWebSocket.instances.length).toBe(1);
|
||||
});
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
// Extract onReconnectFailed callback
|
||||
const initCall = mockInitActivityLogger.mock.calls[0];
|
||||
const onReconnectFailed = initCall[1].onReconnectFailed;
|
||||
expect(onReconnectFailed).toBeDefined();
|
||||
|
||||
// Trigger promotion MAX_PROMOTION_ATTEMPTS + 1 times
|
||||
// Each call should succeed (addNetworkTransport called) until cap is hit
|
||||
mockAddNetworkTransport.mockClear();
|
||||
|
||||
await onReconnectFailed(); // attempt 1
|
||||
await onReconnectFailed(); // attempt 2
|
||||
await onReconnectFailed(); // attempt 3
|
||||
await onReconnectFailed(); // attempt 4 — should be capped
|
||||
|
||||
// Only 3 calls to addNetworkTransport (capped at MAX_PROMOTION_ATTEMPTS)
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
179
packages/cli/src/utils/devtoolsService.ts
Normal file
179
packages/cli/src/utils/devtoolsService.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import WebSocket from 'ws';
|
||||
import { initActivityLogger, addNetworkTransport } from './activityLogger.js';
|
||||
|
||||
interface IDevTools {
|
||||
start(): Promise<string>;
|
||||
stop(): Promise<void>;
|
||||
getPort(): number;
|
||||
}
|
||||
|
||||
const DEVTOOLS_PKG = 'gemini-cli-devtools';
|
||||
const DEFAULT_DEVTOOLS_PORT = 25417;
|
||||
const DEFAULT_DEVTOOLS_HOST = '127.0.0.1';
|
||||
const MAX_PROMOTION_ATTEMPTS = 3;
|
||||
let promotionAttempts = 0;
|
||||
|
||||
/**
|
||||
* Probe whether a DevTools server is already listening on the given host:port.
|
||||
* Returns true if a WebSocket handshake succeeds within a short timeout.
|
||||
*/
|
||||
function probeDevTools(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(`ws://${host}:${port}/ws`);
|
||||
const timer = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve(false);
|
||||
}, 500);
|
||||
|
||||
ws.on('open', () => {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a DevTools server, then check if we won the default port.
|
||||
* If another instance grabbed it first (race), stop ours and connect as client.
|
||||
* Returns { host, port } of the DevTools to connect to.
|
||||
*/
|
||||
async function startOrJoinDevTools(
|
||||
defaultHost: string,
|
||||
defaultPort: number,
|
||||
): Promise<{ host: string; port: number }> {
|
||||
const mod = await import(DEVTOOLS_PKG);
|
||||
const devtools: IDevTools = mod.DevTools.getInstance();
|
||||
const url = await devtools.start();
|
||||
const actualPort = devtools.getPort();
|
||||
|
||||
if (actualPort === defaultPort) {
|
||||
// We won the port — we are the server
|
||||
debugLogger.log(`DevTools available at: ${url}`);
|
||||
return { host: defaultHost, port: actualPort };
|
||||
}
|
||||
|
||||
// Lost the race — someone else has the default port.
|
||||
// Verify the winner is actually alive, then stop ours and connect to theirs.
|
||||
const winnerAlive = await probeDevTools(defaultHost, defaultPort);
|
||||
if (winnerAlive) {
|
||||
await devtools.stop();
|
||||
debugLogger.log(
|
||||
`DevTools (existing) at: http://${defaultHost}:${defaultPort}`,
|
||||
);
|
||||
return { host: defaultHost, port: defaultPort };
|
||||
}
|
||||
|
||||
// Winner isn't responding (maybe also racing and failed) — keep ours
|
||||
debugLogger.log(`DevTools available at: ${url}`);
|
||||
return { host: defaultHost, port: actualPort };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle promotion: when reconnect fails, start or join a DevTools server
|
||||
* and add a new network transport for the logger.
|
||||
*/
|
||||
async function handlePromotion(config: Config) {
|
||||
promotionAttempts++;
|
||||
if (promotionAttempts > MAX_PROMOTION_ATTEMPTS) {
|
||||
debugLogger.debug(
|
||||
`Giving up on DevTools promotion after ${MAX_PROMOTION_ATTEMPTS} attempts`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await startOrJoinDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
addNetworkTransport(config, result.host, result.port, () =>
|
||||
handlePromotion(config),
|
||||
);
|
||||
} catch (err) {
|
||||
debugLogger.debug('Failed to promote to DevTools server:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the activity logger.
|
||||
* Captures network and console logs via DevTools WebSocket or to a file.
|
||||
*
|
||||
* Environment variable GEMINI_CLI_ACTIVITY_LOG_TARGET controls the output:
|
||||
* - file path (e.g., "/tmp/logs.jsonl") → file mode
|
||||
* - not set → auto-start DevTools (reuses existing instance if already running)
|
||||
*
|
||||
* @param config The CLI configuration
|
||||
*/
|
||||
export async function registerActivityLogger(config: Config) {
|
||||
const target = process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET'];
|
||||
|
||||
if (!target) {
|
||||
// No explicit target: try connecting to existing DevTools, then start new one
|
||||
const onReconnectFailed = () => handlePromotion(config);
|
||||
|
||||
// Probe for an existing DevTools server
|
||||
const existing = await probeDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
if (existing) {
|
||||
debugLogger.log(
|
||||
`DevTools (existing) at: http://${DEFAULT_DEVTOOLS_HOST}:${DEFAULT_DEVTOOLS_PORT}`,
|
||||
);
|
||||
initActivityLogger(config, {
|
||||
mode: 'network',
|
||||
host: DEFAULT_DEVTOOLS_HOST,
|
||||
port: DEFAULT_DEVTOOLS_PORT,
|
||||
onReconnectFailed,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No existing server — start (or join if we lose the race)
|
||||
try {
|
||||
const result = await startOrJoinDevTools(
|
||||
DEFAULT_DEVTOOLS_HOST,
|
||||
DEFAULT_DEVTOOLS_PORT,
|
||||
);
|
||||
initActivityLogger(config, {
|
||||
mode: 'network',
|
||||
host: result.host,
|
||||
port: result.port,
|
||||
onReconnectFailed,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
debugLogger.debug(
|
||||
'Failed to start DevTools, falling back to file logging:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// File mode fallback
|
||||
if (!config.storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
initActivityLogger(config, { mode: 'file', filePath: target });
|
||||
}
|
||||
|
||||
/** Reset module-level state — test only. */
|
||||
export function resetForTesting() {
|
||||
promotionAttempts = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user