diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b7ea42f37..068f5bd27fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai - Exec/node: synthesize a local approval plan when a paired node advertises `system.run` without `system.run.prepare`, unblocking approval-required `host=node` exec on current macOS companion nodes while preserving remote prepare for node hosts that support it. Fixes #37591 and duplicate #66839; carries forward #69725. Thanks @soloclz. - Memory/QMD: prefer QMD's `--mask` collection pattern flag so root memory indexing stays scoped to `MEMORY.md` instead of widening to every markdown file in the workspace. Thanks @codex. - Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc. +- CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc. - Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. - Hooks/session-memory: use the host local timezone for memory filenames, fallback timestamp slugs, and markdown headers instead of UTC dates. Fixes #46703. (#46721) Thanks @Astro-Han. diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index b562f417f2c..f8b404ac425 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockGatewayService } from "../../daemon/service.test-helpers.js"; import { captureEnv } from "../../test-utils/env.js"; @@ -340,6 +343,52 @@ describe("gatherDaemonStatus", () => { }); }); + it("uses the fast config path for plain same-file status reads", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-status-config-")); + const configPath = path.join(tmp, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ + gateway: { + bind: "custom", + customBindHost: "10.0.0.5", + controlUi: { enabled: true }, + }, + }), + ); + process.env.OPENCLAW_STATE_DIR = tmp; + process.env.OPENCLAW_CONFIG_PATH = configPath; + serviceReadCommand.mockResolvedValueOnce({ + programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], + environment: { + OPENCLAW_STATE_DIR: tmp, + OPENCLAW_CONFIG_PATH: configPath, + }, + }); + + try { + const status = await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: false, + }); + + expect(readConfigFileSnapshotCalls).not.toHaveBeenCalled(); + expect(loadConfigCalls).not.toHaveBeenCalled(); + expect(status.config?.cli).toMatchObject({ + path: configPath, + exists: true, + valid: true, + controlUi: { enabled: true }, + }); + expect(status.config?.daemon).toBe(status.config?.cli); + expect(status.gateway?.bindMode).toBe("custom"); + expect(status.gateway?.customBindHost).toBe("10.0.0.5"); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } + }); + it("resolves daemon gateway auth password SecretRef values before probing", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index b695956f5c4..173929cfcaf 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -1,3 +1,5 @@ +import fs from "node:fs/promises"; +import JSON5 from "json5"; import { createConfigIO, resolveConfigPath, @@ -66,6 +68,12 @@ type DaemonConfigContext = { configMismatch: boolean; }; +type StatusConfigRead = { + summary: ConfigSummary; + cfg: OpenClawConfig; + mode: "fast" | "full"; +}; + type ResolvedGatewayStatus = { gateway: GatewayStatusSummary; daemonPort: number; @@ -119,6 +127,104 @@ function resolveSnapshotRuntimeConfig(snapshot: ConfigFileSnapshot | null): Open return snapshot.runtimeConfig; } +function coerceStatusConfig(value: unknown): OpenClawConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as OpenClawConfig; +} + +function hasOwnKey(value: unknown, key: string): boolean { + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + Object.prototype.hasOwnProperty.call(value, key), + ); +} + +function needsFullStatusConfigRead(raw: string, parsed: unknown): boolean { + return raw.includes("$include") || raw.includes("${") || hasOwnKey(parsed, "env"); +} + +async function readFastStatusConfig(configPath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(configPath, "utf8"); + } catch { + return null; + } + + let parsed: unknown; + try { + parsed = JSON5.parse(raw); + } catch (err) { + return { + summary: { + path: configPath, + exists: true, + valid: false, + issues: [{ path: "", message: `JSON5 parse failed: ${String(err)}` }], + }, + cfg: {}, + mode: "fast", + }; + } + + if (needsFullStatusConfigRead(raw, parsed)) { + return null; + } + + const cfg = coerceStatusConfig(parsed); + return { + summary: { + path: configPath, + exists: true, + valid: true, + controlUi: cfg.gateway?.controlUi, + }, + cfg, + mode: "fast", + }; +} + +async function readFullStatusConfig(params: { + env: NodeJS.ProcessEnv; + configPath: string; +}): Promise { + const io = createConfigIO({ + env: params.env, + configPath: params.configPath, + pluginValidation: "skip", + }); + const snapshot = await io.readConfigFileSnapshot().catch(() => null); + const cfg = resolveSnapshotRuntimeConfig(snapshot) ?? io.loadConfig(); + return { + summary: { + path: snapshot?.path ?? params.configPath, + exists: snapshot?.exists ?? false, + valid: snapshot?.valid ?? true, + ...(snapshot?.issues?.length ? { issues: snapshot.issues } : {}), + controlUi: cfg.gateway?.controlUi, + }, + cfg, + mode: "full", + }; +} + +async function readStatusConfig(params: { + env: NodeJS.ProcessEnv; + configPath: string; +}): Promise { + return ( + (await readFastStatusConfig(params.configPath)) ?? + (await readFullStatusConfig({ + env: params.env, + configPath: params.configPath, + })) + ); +} + function appendProbeNote( existing: string | undefined, extra: string | undefined, @@ -207,57 +313,27 @@ async function loadDaemonConfigContext( mergedDaemonEnv as NodeJS.ProcessEnv, resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), ); - - const cliIO = createConfigIO({ + const sameConfigPath = cliConfigPath === daemonConfigPath; + const cliConfigRead = await readStatusConfig({ env: process.env, configPath: cliConfigPath, - pluginValidation: "skip", }); - const sharesDaemonConfigContext = !serviceEnv && cliConfigPath === daemonConfigPath; - const daemonIO = sharesDaemonConfigContext - ? cliIO - : createConfigIO({ - env: mergedDaemonEnv, + const sharesDaemonConfigContext = + sameConfigPath && (cliConfigRead.mode === "fast" || !serviceEnv); + const daemonConfigRead = sharesDaemonConfigContext + ? cliConfigRead + : await readStatusConfig({ + env: mergedDaemonEnv as NodeJS.ProcessEnv, configPath: daemonConfigPath, - pluginValidation: "skip", }); - const cliSnapshotPromise = cliIO.readConfigFileSnapshot().catch(() => null); - const daemonSnapshotPromise = sharesDaemonConfigContext - ? cliSnapshotPromise - : daemonIO.readConfigFileSnapshot().catch(() => null); - const [cliSnapshot, daemonSnapshot] = await Promise.all([ - cliSnapshotPromise, - daemonSnapshotPromise, - ]); - const cliCfg = resolveSnapshotRuntimeConfig(cliSnapshot) ?? cliIO.loadConfig(); - const daemonCfg = - sharesDaemonConfigContext && cliSnapshot === daemonSnapshot - ? cliCfg - : (resolveSnapshotRuntimeConfig(daemonSnapshot) ?? daemonIO.loadConfig()); - - const cliConfigSummary: ConfigSummary = { - path: cliSnapshot?.path ?? cliConfigPath, - exists: cliSnapshot?.exists ?? false, - valid: cliSnapshot?.valid ?? true, - ...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}), - controlUi: cliCfg.gateway?.controlUi, - }; - const daemonConfigSummary: ConfigSummary = { - path: daemonSnapshot?.path ?? daemonConfigPath, - exists: daemonSnapshot?.exists ?? false, - valid: daemonSnapshot?.valid ?? true, - ...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), - controlUi: daemonCfg.gateway?.controlUi, - }; - return { mergedDaemonEnv, - cliCfg, - daemonCfg, - cliConfigSummary, - daemonConfigSummary, - configMismatch: cliConfigSummary.path !== daemonConfigSummary.path, + cliCfg: cliConfigRead.cfg, + daemonCfg: daemonConfigRead.cfg, + cliConfigSummary: cliConfigRead.summary, + daemonConfigSummary: daemonConfigRead.summary, + configMismatch: cliConfigRead.summary.path !== daemonConfigRead.summary.path, }; }