Skip to content

Commit 83ee5c0

Browse files
committed
perf(status): defer heavy startup loading
1 parent 9c89a74 commit 83ee5c0

File tree

11 files changed

+334
-16
lines changed

11 files changed

+334
-16
lines changed

src/channels/config-presence.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import type { OpenClawConfig } from "../config/config.js";
4+
import { resolveOAuthDir } from "../config/paths.js";
5+
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
6+
7+
const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]);
8+
9+
const CHANNEL_ENV_PREFIXES = [
10+
"BLUEBUBBLES_",
11+
"DISCORD_",
12+
"GOOGLECHAT_",
13+
"IRC_",
14+
"LINE_",
15+
"MATRIX_",
16+
"MSTEAMS_",
17+
"SIGNAL_",
18+
"SLACK_",
19+
"TELEGRAM_",
20+
"WHATSAPP_",
21+
"ZALOUSER_",
22+
"ZALO_",
23+
] as const;
24+
25+
function hasNonEmptyString(value: unknown): boolean {
26+
return typeof value === "string" && value.trim().length > 0;
27+
}
28+
29+
function isRecord(value: unknown): value is Record<string, unknown> {
30+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
31+
}
32+
33+
function recordHasKeys(value: unknown): boolean {
34+
return isRecord(value) && Object.keys(value).length > 0;
35+
}
36+
37+
function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean {
38+
try {
39+
const oauthDir = resolveOAuthDir(env);
40+
const legacyCreds = path.join(oauthDir, "creds.json");
41+
if (fs.existsSync(legacyCreds)) {
42+
return true;
43+
}
44+
45+
const accountsRoot = path.join(oauthDir, "whatsapp");
46+
const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json");
47+
if (fs.existsSync(defaultCreds)) {
48+
return true;
49+
}
50+
51+
const entries = fs.readdirSync(accountsRoot, { withFileTypes: true });
52+
return entries.some((entry) => {
53+
if (!entry.isDirectory()) {
54+
return false;
55+
}
56+
return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json"));
57+
});
58+
} catch {
59+
return false;
60+
}
61+
}
62+
63+
function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean {
64+
for (const [key, value] of Object.entries(env)) {
65+
if (!hasNonEmptyString(value)) {
66+
continue;
67+
}
68+
if (
69+
CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) ||
70+
key === "TELEGRAM_BOT_TOKEN"
71+
) {
72+
return true;
73+
}
74+
}
75+
return hasWhatsAppAuthState(env);
76+
}
77+
78+
export function hasPotentialConfiguredChannels(
79+
cfg: OpenClawConfig,
80+
env: NodeJS.ProcessEnv = process.env,
81+
): boolean {
82+
const channels = isRecord(cfg.channels) ? cfg.channels : null;
83+
if (channels) {
84+
for (const [key, value] of Object.entries(channels)) {
85+
if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) {
86+
continue;
87+
}
88+
if (recordHasKeys(value)) {
89+
return true;
90+
}
91+
}
92+
}
93+
return hasEnvConfiguredChannel(env);
94+
}

src/cli/program/preaction.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,19 @@ describe("registerPreActionHooks", () => {
190190
});
191191

192192
it("applies --json stdout suppression only for explicit JSON output commands", async () => {
193+
await runPreAction({
194+
parseArgv: ["status"],
195+
processArgv: ["node", "openclaw", "status", "--json"],
196+
});
197+
198+
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
199+
runtime: runtimeMock,
200+
commandPath: ["status"],
201+
suppressDoctorStdout: true,
202+
});
203+
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
204+
205+
vi.clearAllMocks();
193206
await runPreAction({
194207
parseArgv: ["update", "status", "--json"],
195208
processArgv: ["node", "openclaw", "update", "status", "--json"],
@@ -200,6 +213,7 @@ describe("registerPreActionHooks", () => {
200213
commandPath: ["update", "status"],
201214
suppressDoctorStdout: true,
202215
});
216+
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
203217

204218
vi.clearAllMocks();
205219
await runPreAction({

src/cli/program/preaction.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" {
7171
return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all";
7272
}
7373

74+
function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean {
75+
if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
76+
return false;
77+
}
78+
if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) {
79+
return false;
80+
}
81+
return true;
82+
}
83+
7484
function getRootCommand(command: Command): Command {
7585
let current = command;
7686
while (current.parent) {
@@ -138,7 +148,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
138148
...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}),
139149
});
140150
// Load plugins for commands that need channel access
141-
if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) {
151+
if (shouldLoadPluginsForCommand(commandPath, argv)) {
142152
const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule();
143153
ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) });
144154
}

src/cli/program/routes.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ const routeHealth: RouteSpec = {
3434

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

src/cli/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe("tryRouteCli", () => {
3737
vi.resetModules();
3838
({ tryRouteCli } = await import("./route.js"));
3939
findRoutedCommandMock.mockReturnValue({
40-
loadPlugins: true,
40+
loadPlugins: (argv: string[]) => !argv.includes("--json"),
4141
run: runRouteMock,
4242
});
4343
});
@@ -59,7 +59,7 @@ describe("tryRouteCli", () => {
5959
suppressDoctorStdout: true,
6060
}),
6161
);
62-
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" });
62+
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
6363
});
6464

6565
it("does not pass suppressDoctorStdout for routed non-json commands", async () => {

src/commands/status.command.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
55
import { info } from "../globals.js";
66
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
77
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
8-
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
98
import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js";
109
import { formatGitInstallLabel } from "../infra/update-check.js";
1110
import {
@@ -37,6 +36,13 @@ import {
3736
resolveUpdateAvailability,
3837
} from "./status.update.js";
3938

39+
let providerUsagePromise: Promise<typeof import("../infra/provider-usage.js")> | undefined;
40+
41+
function loadProviderUsage() {
42+
providerUsagePromise ??= import("../infra/provider-usage.js");
43+
return providerUsagePromise;
44+
}
45+
4046
function resolvePairingRecoveryContext(params: {
4147
error?: string | null;
4248
closeReason?: string | null;
@@ -138,7 +144,10 @@ export async function statusCommand(
138144
indeterminate: true,
139145
enabled: opts.json !== true,
140146
},
141-
async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
147+
async () => {
148+
const { loadProviderUsageSummary } = await loadProviderUsage();
149+
return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs });
150+
},
142151
)
143152
: undefined;
144153
const health: HealthSummary | undefined = opts.deep
@@ -658,6 +667,7 @@ export async function statusCommand(
658667
}
659668

660669
if (usage) {
670+
const { formatUsageReportLines } = await loadProviderUsage();
661671
runtime.log("");
662672
runtime.log(theme.heading("Usage"));
663673
for (const line of formatUsageReportLines(usage)) {

0 commit comments

Comments
 (0)