mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-22 06:32:00 +00:00
perf(status): defer heavy startup loading
This commit is contained in:
94
src/channels/config-presence.ts
Normal file
94
src/channels/config-presence.ts
Normal 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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user