From bc75968074f1c58b4a9bc9ea914be60919c92977 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 4 Apr 2026 08:59:42 +0100 Subject: [PATCH] perf(cli): trim gateway status startup imports --- src/cli/daemon-cli/shared.ts | 10 +- src/cli/daemon-cli/status.gather.test.ts | 43 +++++- src/cli/daemon-cli/status.gather.ts | 187 +++++++++++++++++------ src/cli/daemon-cli/status.print.test.ts | 7 +- src/cli/daemon-cli/status.print.ts | 13 +- src/cli/route.ts | 4 +- src/config/io.ts | 2 +- src/logging/log-file-path.ts | 41 +++++ 8 files changed, 231 insertions(+), 76 deletions(-) create mode 100644 src/logging/log-file-path.ts diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 443f1046b4a2..dbdc3940fcbd 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -10,7 +10,6 @@ import { buildPlatformRuntimeLogHints, buildPlatformServiceStartHints, } from "../../daemon/runtime-hints.js"; -import { getResolvedLoggerSettings } from "../../logging.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { formatCliCommand } from "../command-format.js"; import { parsePort } from "../shared/parse-port.js"; @@ -147,18 +146,13 @@ export function normalizeListenerAddress(raw: string): string { export function renderRuntimeHints( runtime: { missingUnit?: boolean; status?: string } | undefined, env: NodeJS.ProcessEnv = process.env, + logFile?: string | null, ): string[] { if (!runtime) { return []; } const hints: string[] = []; - const fileLog = (() => { - try { - return getResolvedLoggerSettings().file; - } catch { - return null; - } - })(); + const fileLog = logFile ?? null; if (runtime.missingUnit) { hints.push(`Service not installed. Run: ${formatCliCommand("openclaw gateway install", env)}`); if (fileLog) { diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 38dad085d4c2..e14f01ec55dd 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -58,6 +58,8 @@ const resolveStateDir = vi.fn( const resolveConfigPath = vi.fn((env: NodeJS.ProcessEnv, stateDir: string) => { return env.OPENCLAW_CONFIG_PATH ?? `${stateDir}/openclaw.json`; }); +const readConfigFileSnapshotCalls = vi.fn((configPath: string) => configPath); +const loadConfigCalls = vi.fn((configPath: string) => configPath); let daemonLoadedConfig: Record = { gateway: { bind: "lan", @@ -74,14 +76,23 @@ let cliLoadedConfig: Record = { vi.mock("../../config/config.js", () => ({ createConfigIO: ({ configPath }: { configPath: string }) => { const isDaemon = configPath.includes("/openclaw-daemon/"); + const runtimeConfig = isDaemon ? daemonLoadedConfig : cliLoadedConfig; return { - readConfigFileSnapshot: async () => ({ - path: configPath, - exists: true, - valid: true, - issues: [], - }), - loadConfig: () => (isDaemon ? daemonLoadedConfig : cliLoadedConfig), + readConfigFileSnapshot: async () => { + readConfigFileSnapshotCalls(configPath); + return { + path: configPath, + exists: true, + valid: true, + issues: [], + runtimeConfig, + config: runtimeConfig, + }; + }, + loadConfig: () => { + loadConfigCalls(configPath); + return runtimeConfig; + }, }; }, loadConfig: () => cliLoadedConfig, @@ -158,6 +169,8 @@ describe("gatherDaemonStatus", () => { callGatewayStatusProbe.mockClear(); loadGatewayTlsRuntime.mockClear(); inspectGatewayRestart.mockClear(); + readConfigFileSnapshotCalls.mockClear(); + loadConfigCalls.mockClear(); daemonLoadedConfig = { gateway: { bind: "lan", @@ -212,6 +225,22 @@ describe("gatherDaemonStatus", () => { ); }); + it("reuses the shared CLI config snapshot when the daemon uses the same config path", async () => { + serviceReadCommand.mockResolvedValueOnce({ + programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], + }); + + await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(readConfigFileSnapshotCalls).toHaveBeenCalledTimes(1); + expect(readConfigFileSnapshotCalls).toHaveBeenCalledWith("/tmp/openclaw-cli/openclaw.json"); + expect(loadConfigCalls).not.toHaveBeenCalled(); + }); + it("does not force local TLS fingerprint when probe URL is explicitly overridden", async () => { const status = await gatherDaemonStatus({ rpc: { url: "wss://override.example:18790" }, diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index aaa367af3653..eedad89cd179 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -6,18 +6,20 @@ import { } from "../../config/config.js"; import type { OpenClawConfig, + ConfigFileSnapshot, GatewayBindMode, GatewayControlUiConfig, } from "../../config/types.js"; import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js"; import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js"; -import { findExtraGatewayServices } from "../../daemon/inspect.js"; import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; -import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; -import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; +import { + isGatewaySecretRefUnavailableError, + resolveGatewayProbeCredentialsFromConfig, + trimToUndefined, +} from "../../gateway/credentials.js"; import { inspectBestEffortPrimaryTailnetIPv4, resolveBestEffortGatewayBindHostForDisplay, @@ -29,9 +31,7 @@ import { type PortListener, type PortUsageStatus, } from "../../infra/ports.js"; -import { loadGatewayTlsRuntime } from "../../infra/tls/gateway.js"; -import { probeGatewayStatus } from "./probe.js"; -import { inspectGatewayRestart } from "./restart-health.js"; +import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; import type { GatewayRpcOpts } from "./types.js"; @@ -76,6 +76,52 @@ type ResolvedGatewayStatus = { probeUrlOverride: string | null; }; +let gatewayProbeAuthModulePromise: + | Promise + | undefined; +let daemonInspectModulePromise: Promise | undefined; +let serviceAuditModulePromise: Promise | undefined; +let gatewayTlsModulePromise: Promise | undefined; +let daemonProbeModulePromise: Promise | undefined; +let restartHealthModulePromise: Promise | undefined; + +function loadGatewayProbeAuthModule() { + gatewayProbeAuthModulePromise ??= import("../../gateway/probe-auth.js"); + return gatewayProbeAuthModulePromise; +} + +function loadDaemonInspectModule() { + daemonInspectModulePromise ??= import("../../daemon/inspect.js"); + return daemonInspectModulePromise; +} + +function loadServiceAuditModule() { + serviceAuditModulePromise ??= import("../../daemon/service-audit.js"); + return serviceAuditModulePromise; +} + +function loadGatewayTlsModule() { + gatewayTlsModulePromise ??= import("../../infra/tls/gateway.js"); + return gatewayTlsModulePromise; +} + +function loadDaemonProbeModule() { + daemonProbeModulePromise ??= import("./probe.js"); + return daemonProbeModulePromise; +} + +function loadRestartHealthModule() { + restartHealthModulePromise ??= import("./restart-health.js"); + return restartHealthModulePromise; +} + +function resolveSnapshotRuntimeConfig(snapshot: ConfigFileSnapshot | null): OpenClawConfig | null { + if (!snapshot?.valid || !snapshot.runtimeConfig) { + return null; + } + return snapshot.runtimeConfig; +} + function appendProbeNote( existing: string | undefined, extra: string | undefined, @@ -87,6 +133,7 @@ function appendProbeNote( return [...new Set(values)].join(" "); } export type DaemonStatus = { + logFile?: string; service: { label: string; loaded: boolean; @@ -162,17 +209,27 @@ async function loadDaemonConfigContext( ); const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath }); - const daemonIO = createConfigIO({ - env: mergedDaemonEnv, - configPath: daemonConfigPath, - }); + const sharesDaemonConfigContext = !serviceEnv && cliConfigPath === daemonConfigPath; + const daemonIO = sharesDaemonConfigContext + ? cliIO + : createConfigIO({ + env: mergedDaemonEnv, + configPath: daemonConfigPath, + }); + const cliSnapshotPromise = cliIO.readConfigFileSnapshot().catch(() => null); + const daemonSnapshotPromise = sharesDaemonConfigContext + ? cliSnapshotPromise + : daemonIO.readConfigFileSnapshot().catch(() => null); const [cliSnapshot, daemonSnapshot] = await Promise.all([ - cliIO.readConfigFileSnapshot().catch(() => null), - daemonIO.readConfigFileSnapshot().catch(() => null), + cliSnapshotPromise, + daemonSnapshotPromise, ]); - const cliCfg = cliIO.loadConfig(); - const daemonCfg = daemonIO.loadConfig(); + const cliCfg = resolveSnapshotRuntimeConfig(cliSnapshot) ?? cliIO.loadConfig(); + const daemonCfg = + sharesDaemonConfigContext && cliSnapshot === daemonSnapshot + ? cliCfg + : (resolveSnapshotRuntimeConfig(daemonSnapshot) ?? daemonIO.loadConfig()); const cliConfigSummary: ConfigSummary = { path: cliSnapshot?.path ?? cliConfigPath, @@ -300,10 +357,14 @@ export async function gatherDaemonStatus( service.isLoaded({ env: serviceEnv }).catch(() => false), service.readRuntime(serviceEnv).catch((err) => ({ status: "unknown", detail: String(err) })), ]); - const configAudit = await auditGatewayServiceConfig({ - env: process.env, - command, - }); + const configAudit = command + ? await loadServiceAuditModule().then(({ auditGatewayServiceConfig }) => + auditGatewayServiceConfig({ + env: process.env, + command, + }), + ) + : { ok: true, issues: [] satisfies ServiceConfigAudit["issues"] }; const { mergedDaemonEnv, cliCfg, @@ -324,25 +385,33 @@ export async function gatherDaemonStatus( cliPort, }); - const extraServices = await findExtraGatewayServices( - process.env as Record, - { deep: Boolean(opts.deep) }, - ).catch(() => []); + const extraServices = opts.deep + ? await loadDaemonInspectModule() + .then(({ findExtraGatewayServices }) => + findExtraGatewayServices(process.env as Record, { + deep: true, + }), + ) + .catch(() => []) + : []; const timeoutMs = parseStrictPositiveInteger(opts.rpc.timeout ?? "10000") ?? 10_000; const tlsEnabled = daemonCfg.gateway?.tls?.enabled === true; const shouldUseLocalTlsRuntime = opts.probe && !probeUrlOverride && tlsEnabled; const tlsRuntime = shouldUseLocalTlsRuntime - ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) + ? await loadGatewayTlsModule().then(({ loadGatewayTlsRuntime }) => + loadGatewayTlsRuntime(daemonCfg.gateway?.tls), + ) : undefined; let daemonProbeAuth: { token?: string; password?: string } | undefined; let rpcAuthWarning: string | undefined; if (opts.probe) { + const probeMode = daemonCfg.gateway?.mode === "remote" ? "remote" : "local"; try { - daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ + daemonProbeAuth = resolveGatewayProbeCredentialsFromConfig({ cfg: daemonCfg, - mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", + mode: probeMode, env: mergedDaemonEnv as NodeJS.ProcessEnv, explicitAuth: { token: opts.rpc.token, @@ -354,36 +423,61 @@ export async function gatherDaemonStatus( if (!refPath) { throw error; } - daemonProbeAuth = undefined; - rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + try { + daemonProbeAuth = await loadGatewayProbeAuthModule().then( + ({ resolveGatewayProbeAuthWithSecretInputs }) => + resolveGatewayProbeAuthWithSecretInputs({ + cfg: daemonCfg, + mode: probeMode, + env: mergedDaemonEnv as NodeJS.ProcessEnv, + explicitAuth: { + token: opts.rpc.token, + password: opts.rpc.password, + }, + }), + ); + } catch (fallbackError) { + const fallbackRefPath = parseGatewaySecretRefPathFromError(fallbackError); + if (!fallbackRefPath) { + throw fallbackError; + } + daemonProbeAuth = undefined; + rpcAuthWarning = `${fallbackRefPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + } } } const rpc = opts.probe - ? await probeGatewayStatus({ - url: gateway.probeUrl, - token: daemonProbeAuth?.token, - password: daemonProbeAuth?.password, - tlsFingerprint: - shouldUseLocalTlsRuntime && tlsRuntime?.enabled - ? tlsRuntime.fingerprintSha256 - : undefined, - timeoutMs, - json: opts.rpc.json, - requireRpc: opts.requireRpc, - configPath: daemonConfigSummary.path, - }) + ? await loadDaemonProbeModule().then(({ probeGatewayStatus }) => + probeGatewayStatus({ + url: gateway.probeUrl, + token: daemonProbeAuth?.token, + password: daemonProbeAuth?.password, + tlsFingerprint: + shouldUseLocalTlsRuntime && tlsRuntime?.enabled + ? tlsRuntime.fingerprintSha256 + : undefined, + timeoutMs, + json: opts.rpc.json, + requireRpc: opts.requireRpc, + configPath: daemonConfigSummary.path, + }), + ) : undefined; if (rpc?.ok) { rpcAuthWarning = undefined; } const health = opts.probe && loaded - ? await inspectGatewayRestart({ - service, - port: daemonPort, - env: serviceEnv, - }).catch(() => undefined) + ? await loadRestartHealthModule() + .then(({ inspectGatewayRestart }) => + inspectGatewayRestart({ + service, + port: daemonPort, + env: serviceEnv, + }), + ) + .catch(() => undefined) : undefined; let lastError: string | undefined; @@ -392,6 +486,7 @@ export async function gatherDaemonStatus( } return { + logFile: resolveConfiguredLogFilePath(cliCfg), service: { label: service.label, loaded, diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 63f9022e88da..6355c0206c62 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -20,7 +20,7 @@ vi.mock("../../terminal/theme.js", async () => { }; }); -vi.mock("../../commands/onboard-helpers.js", () => ({ +vi.mock("../../gateway/control-ui-links.js", () => ({ resolveControlUiLinks: () => ({ httpUrl: "http://127.0.0.1:18789" }), })); @@ -44,10 +44,6 @@ vi.mock("../../infra/wsl.js", () => ({ isWSLEnv: () => false, })); -vi.mock("../../logging.js", () => ({ - getResolvedLoggerSettings: () => ({ file: "/tmp/openclaw.log" }), -})); - vi.mock("./shared.js", () => ({ createCliStatusTextStyles: () => ({ rich: false, @@ -87,6 +83,7 @@ describe("printDaemonStatus", () => { notLoadedText: "not loaded", runtime: { status: "running", pid: 8000 }, }, + logFile: "/tmp/openclaw.log", gateway: { bindMode: "loopback", bindHost: "127.0.0.1", diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index ab9031c6a8f9..620b13c3eaa8 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -1,4 +1,3 @@ -import { resolveControlUiLinks } from "../../commands/onboard-helpers.js"; import { formatConfigIssueLine } from "../../config/issue-format.js"; import { resolveGatewayLaunchAgentLabel, @@ -11,8 +10,8 @@ import { renderSystemdUnavailableHints, } from "../../daemon/systemd-hints.js"; import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailable.js"; +import { resolveControlUiLinks } from "../../gateway/control-ui-links.js"; import { isWSLEnv } from "../../infra/wsl.js"; -import { getResolvedLoggerSettings } from "../../logging.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; @@ -67,11 +66,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) ? okText(service.loadedText) : warnText(service.notLoadedText); defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`); - try { - const logFile = getResolvedLoggerSettings().file; - defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(logFile))}`); - } catch { - // ignore missing config/log resolution + if (status.logFile) { + defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(status.logFile))}`); } if (service.command?.programArguments?.length) { defaultRuntime.log( @@ -237,7 +233,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) if (service.runtime?.missingUnit) { defaultRuntime.error(errorText("Service unit not found.")); - for (const hint of renderRuntimeHints(service.runtime)) { + for (const hint of renderRuntimeHints(service.runtime, process.env, status.logFile)) { defaultRuntime.error(errorText(hint)); } } else if (service.loaded && service.runtime?.status === "stopped") { @@ -247,6 +243,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) for (const hint of renderRuntimeHints( service.runtime, service.command?.environment ?? process.env, + status.logFile, )) { defaultRuntime.error(errorText(hint)); } diff --git a/src/cli/route.ts b/src/cli/route.ts index 33aef1d97706..ec87fcd46d12 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -10,7 +10,9 @@ async function prepareRoutedCommand(params: { loadPlugins?: boolean | ((argv: string[]) => boolean); }) { const suppressDoctorStdout = hasFlag(params.argv, "--json"); - const skipConfigGuard = params.commandPath[0] === "status" && suppressDoctorStdout; + const skipConfigGuard = + (params.commandPath[0] === "status" && suppressDoctorStdout) || + (params.commandPath[0] === "gateway" && params.commandPath[1] === "status"); if (!suppressDoctorStdout && process.stdout.isTTY) { const [{ emitCliBanner }, { VERSION }] = await Promise.all([ import("./banner.js"), diff --git a/src/config/io.ts b/src/config/io.ts index 0cd235558af5..c31ad20013b1 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1889,7 +1889,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const exists = deps.fs.existsSync(configPath); if (!exists) { const hash = hashConfigRaw(null); - const config = materializeRuntimeConfig({}, "missing"); + const config = {}; const legacyIssues: LegacyConfigIssue[] = []; return await finalizeReadConfigSnapshotInternalResult(deps, { snapshot: createConfigFileSnapshot({ diff --git a/src/logging/log-file-path.ts b/src/logging/log-file-path.ts new file mode 100644 index 000000000000..cb9b4b615f0c --- /dev/null +++ b/src/logging/log-file-path.ts @@ -0,0 +1,41 @@ +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.js"; +import { + POSIX_OPENCLAW_TMP_DIR, + resolvePreferredOpenClawTmpDir, +} from "../infra/tmp-openclaw-dir.js"; + +const LOG_PREFIX = "openclaw"; +const LOG_SUFFIX = ".log"; + +function canUseNodeFs(): boolean { + const getBuiltinModule = ( + process as NodeJS.Process & { + getBuiltinModule?: (id: string) => unknown; + } + ).getBuiltinModule; + if (typeof getBuiltinModule !== "function") { + return false; + } + try { + return getBuiltinModule("fs") !== undefined; + } catch { + return false; + } +} + +function formatLocalDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export function resolveDefaultRollingLogFile(date = new Date()): string { + const logDir = canUseNodeFs() ? resolvePreferredOpenClawTmpDir() : POSIX_OPENCLAW_TMP_DIR; + return path.join(logDir, `${LOG_PREFIX}-${formatLocalDate(date)}${LOG_SUFFIX}`); +} + +export function resolveConfiguredLogFilePath(config?: OpenClawConfig | null): string { + return config?.logging?.file ?? resolveDefaultRollingLogFile(); +}