perf(status): defer heavy startup loading

This commit is contained in:
Peter Steinberger
2026-03-15 18:20:37 -07:00
parent 9c89a74f84
commit 83ee5c0328
11 changed files with 334 additions and 16 deletions

View File

@@ -0,0 +1,94 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOAuthDir } from "../config/paths.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
const CHANNEL_ENV_PREFIXES = [
"BLUEBUBBLES_",
"DISCORD_",
"GOOGLECHAT_",
"IRC_",
"LINE_",
"MATRIX_",
"MSTEAMS_",
"SIGNAL_",
"SLACK_",
"TELEGRAM_",
"WHATSAPP_",
"ZALOUSER_",
"ZALO_",
] as const;
function hasNonEmptyString(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function recordHasKeys(value: unknown): boolean {
return isRecord(value) && Object.keys(value).length > 0;
}
function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean {
try {
const oauthDir = resolveOAuthDir(env);
const legacyCreds = path.join(oauthDir, "creds.json");
if (fs.existsSync(legacyCreds)) {
return true;
}
const accountsRoot = path.join(oauthDir, "whatsapp");
const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json");
if (fs.existsSync(defaultCreds)) {
return true;
}
const entries = fs.readdirSync(accountsRoot, { withFileTypes: true });
return entries.some((entry) => {
if (!entry.isDirectory()) {
return false;
}
return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json"));
});
} catch {
return false;
}
}
function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean {
for (const [key, value] of Object.entries(env)) {
if (!hasNonEmptyString(value)) {
continue;
}
if (
CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
key === "TELEGRAM_BOT_TOKEN"
) {
return true;
}
}
return hasWhatsAppAuthState(env);
}
export function hasPotentialConfiguredChannels(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
): boolean {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (channels) {
for (const [key, value] of Object.entries(channels)) {
if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) {
continue;
}
if (recordHasKeys(value)) {
return true;
}
}
}
return hasEnvConfiguredChannel(env);
}

View File

@@ -190,6 +190,19 @@ describe("registerPreActionHooks", () => {
});
it("applies --json stdout suppression only for explicit JSON output commands", async () => {
await runPreAction({
parseArgv: ["status"],
processArgv: ["node", "openclaw", "status", "--json"],
});
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["status"],
suppressDoctorStdout: true,
});
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
vi.clearAllMocks();
await runPreAction({
parseArgv: ["update", "status", "--json"],
processArgv: ["node", "openclaw", "update", "status", "--json"],
@@ -200,6 +213,7 @@ describe("registerPreActionHooks", () => {
commandPath: ["update", "status"],
suppressDoctorStdout: true,
});
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
vi.clearAllMocks();
await runPreAction({

View File

@@ -71,6 +71,16 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" {
return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all";
}
function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean {
if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
return false;
}
if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) {
return false;
}
return true;
}
function getRootCommand(command: Command): Command {
let current = command;
while (current.parent) {
@@ -138,7 +148,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}),
});
// Load plugins for commands that need channel access
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
if (shouldLoadPluginsForCommand(commandPath, argv)) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) });
}

View File

@@ -34,9 +34,9 @@ const routeHealth: RouteSpec = {
const routeStatus: RouteSpec = {
match: (path) => path[0] === "status",
// Status runs security audit with channel checks in both text and JSON output,
// so plugin registry must be ready for consistent findings.
loadPlugins: true,
// `status --json` can defer channel plugin loading until config/env inspection
// proves it is needed, which keeps the fast-path startup lightweight.
loadPlugins: (argv) => !hasFlag(argv, "--json"),
run: async (argv) => {
const json = hasFlag(argv, "--json");
const deep = hasFlag(argv, "--deep");

View File

@@ -37,7 +37,7 @@ describe("tryRouteCli", () => {
vi.resetModules();
({ tryRouteCli } = await import("./route.js"));
findRoutedCommandMock.mockReturnValue({
loadPlugins: true,
loadPlugins: (argv: string[]) => !argv.includes("--json"),
run: runRouteMock,
});
});
@@ -59,7 +59,7 @@ describe("tryRouteCli", () => {
suppressDoctorStdout: true,
}),
);
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" });
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
});
it("does not pass suppressDoctorStdout for routed non-json commands", async () => {

View File

@@ -5,7 +5,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js";
import { formatGitInstallLabel } from "../infra/update-check.js";
import {
@@ -37,6 +36,13 @@ import {
resolveUpdateAvailability,
} from "./status.update.js";
let providerUsagePromise: Promise<typeof import("../infra/provider-usage.js")> | undefined;
function loadProviderUsage() {
providerUsagePromise ??= import("../infra/provider-usage.js");
return providerUsagePromise;
}
function resolvePairingRecoveryContext(params: {
error?: string | null;
closeReason?: string | null;
@@ -138,7 +144,10 @@ export async function statusCommand(
indeterminate: true,
enabled: opts.json !== true,
},
async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
async () => {
const { loadProviderUsageSummary } = await loadProviderUsage();
return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs });
},
)
: undefined;
const health: HealthSummary | undefined = opts.deep
@@ -658,6 +667,7 @@ export async function statusCommand(
}
if (usage) {
const { formatUsageReportLines } = await loadProviderUsage();
runtime.log("");
runtime.log(theme.heading("Usage"));
for (const line of formatUsageReportLines(usage)) {

View File

@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
buildGatewayConnectionDetails: vi.fn(),
probeGateway: vi.fn(),
resolveGatewayProbeAuthResolution: vi.fn(),
ensurePluginRegistryLoaded: vi.fn(),
}));
vi.mock("../cli/progress.js", () => ({
@@ -70,6 +71,10 @@ vi.mock("../process/exec.js", () => ({
runExec: vi.fn(),
}));
vi.mock("../cli/plugin-registry.js", () => ({
ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded,
}));
import { scanStatus } from "./status.scan.js";
describe("scanStatus", () => {
@@ -135,4 +140,172 @@ describe("scanStatus", () => {
}),
);
});
it("skips channel plugin preload for status --json with no channel config", async () => {
mocks.readBestEffortConfig.mockResolvedValue({
session: {},
plugins: { enabled: false },
gateway: {},
});
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: {
session: {},
plugins: { enabled: false },
gateway: {},
},
diagnostics: [],
});
mocks.getUpdateCheckResult.mockResolvedValue({
installKind: "git",
git: null,
registry: null,
});
mocks.getAgentLocalStatuses.mockResolvedValue({
defaultId: "main",
agents: [],
});
mocks.getStatusSummary.mockResolvedValue({
linkChannel: undefined,
sessions: { count: 0, paths: [], defaults: {}, recent: [] },
});
mocks.buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "default",
});
mocks.resolveGatewayProbeAuthResolution.mockReturnValue({
auth: {},
warning: undefined,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
await scanStatus({ json: true }, {} as never);
expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled();
});
it("preloads channel plugins for status --json when channel config exists", async () => {
mocks.readBestEffortConfig.mockResolvedValue({
session: {},
plugins: { enabled: false },
gateway: {},
channels: { telegram: { enabled: false } },
});
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: {
session: {},
plugins: { enabled: false },
gateway: {},
channels: { telegram: { enabled: false } },
},
diagnostics: [],
});
mocks.getUpdateCheckResult.mockResolvedValue({
installKind: "git",
git: null,
registry: null,
});
mocks.getAgentLocalStatuses.mockResolvedValue({
defaultId: "main",
agents: [],
});
mocks.getStatusSummary.mockResolvedValue({
linkChannel: { linked: false },
sessions: { count: 0, paths: [], defaults: {}, recent: [] },
});
mocks.buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "default",
});
mocks.resolveGatewayProbeAuthResolution.mockReturnValue({
auth: {},
warning: undefined,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
await scanStatus({ json: true }, {} as never);
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" });
});
it("preloads channel plugins for status --json when channel auth is env-only", async () => {
const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN;
process.env.MATRIX_ACCESS_TOKEN = "token";
mocks.readBestEffortConfig.mockResolvedValue({
session: {},
plugins: { enabled: false },
gateway: {},
});
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: {
session: {},
plugins: { enabled: false },
gateway: {},
},
diagnostics: [],
});
mocks.getUpdateCheckResult.mockResolvedValue({
installKind: "git",
git: null,
registry: null,
});
mocks.getAgentLocalStatuses.mockResolvedValue({
defaultId: "main",
agents: [],
});
mocks.getStatusSummary.mockResolvedValue({
linkChannel: { linked: false },
sessions: { count: 0, paths: [], defaults: {}, recent: [] },
});
mocks.buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "default",
});
mocks.resolveGatewayProbeAuthResolution.mockReturnValue({
auth: {},
warning: undefined,
});
mocks.probeGateway.mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
try {
await scanStatus({ json: true }, {} as never);
} finally {
if (prevMatrixToken === undefined) {
delete process.env.MATRIX_ACCESS_TOKEN;
} else {
process.env.MATRIX_ACCESS_TOKEN = prevMatrixToken;
}
}
expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" });
});
});

View File

@@ -1,3 +1,4 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js";
import { withProgress } from "../cli/progress.js";
@@ -46,6 +47,13 @@ type GatewayProbeSnapshot = {
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
};
let pluginRegistryModulePromise: Promise<typeof import("../cli/plugin-registry.js")> | undefined;
function loadPluginRegistryModule() {
pluginRegistryModulePromise ??= import("../cli/plugin-registry.js");
return pluginRegistryModulePromise;
}
function deferResult<T>(promise: Promise<T>): Promise<DeferredResult<T>> {
return promise.then(
(value) => ({ ok: true, value }),
@@ -191,6 +199,10 @@ async function scanStatusJsonFast(opts: {
targetIds: getStatusCommandSecretTargetIds(),
mode: "summary",
});
if (hasPotentialConfiguredChannels(cfg)) {
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
ensurePluginRegistryLoaded({ scope: "channels" });
}
const osSummary = resolveOsSummary();
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const updateTimeoutMs = opts.all ? 6500 : 2500;

View File

@@ -1,6 +1,7 @@
import { resolveContextTokensForModel } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import {
@@ -89,7 +90,8 @@ export async function getStatusSummary(
): Promise<StatusSummary> {
const { includeSensitive = true } = options;
const cfg = options.config ?? loadConfig();
const linkContext = await resolveLinkChannelContext(cfg);
const needsChannelPlugins = hasPotentialConfiguredChannels(cfg);
const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null;
const agentList = listAgentsForGateway(cfg);
const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => {
const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id);
@@ -100,11 +102,13 @@ export async function getStatusSummary(
everyMs: summary.everyMs,
} satisfies HeartbeatStatus;
});
const channelSummary = await buildChannelSummary(cfg, {
colorize: true,
includeAllowFrom: true,
sourceConfig: options.sourceConfig,
});
const channelSummary = needsChannelPlugins
? await buildChannelSummary(cfg, {
colorize: true,
includeAllowFrom: true,
sourceConfig: options.sourceConfig,
})
: [];
const mainSessionKey = resolveMainSessionKey(cfg);
const queuedSystemEvents = peekSystemEvents(mainSessionKey);

View File

@@ -398,7 +398,7 @@ describe("statusCommand", () => {
it("prints JSON when requested", async () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0]));
expect(payload.linkChannel.linked).toBe(true);
expect(payload.linkChannel).toBeUndefined();
expect(payload.memory.agentId).toBe("main");
expect(payload.memoryPlugin.enabled).toBe(true);
expect(payload.memoryPlugin.slot).toBe("memory-core");

View File

@@ -5,6 +5,7 @@ import { execDockerRaw } from "../agents/sandbox/docker.js";
import { redactCdpUrl } from "../browser/cdp.helpers.js";
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
import { resolveBrowserControlAuth } from "../browser/control-auth.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
@@ -1226,7 +1227,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
}
}
if (context.includeChannelSecurity) {
if (context.includeChannelSecurity && hasPotentialConfiguredChannels(cfg, env)) {
const plugins = context.plugins ?? listChannelPlugins();
findings.push(
...(await collectChannelSecurityFindings({