From f86840f4dff4484eb1db8c70dc7e6faac5df219a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 01:18:20 +0000 Subject: [PATCH] perf(cli): reduce read-only startup overhead --- scripts/run-node.mjs | 93 ++++++++++++++++++++++-- src/cli/argv.test.ts | 6 ++ src/cli/argv.ts | 6 ++ src/cli/program/config-guard.test.ts | 50 +++++++++++++ src/cli/program/config-guard.ts | 8 ++- src/cli/program/routes.ts | 104 +++++++++++++++++++++++++++ src/cli/run-main.test.ts | 40 +++++++++++ src/cli/run-main.ts | 36 ++++++++-- 8 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 src/cli/program/config-guard.test.ts diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index e02720a14f..d06f70e556 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import process from "node:process"; @@ -15,6 +15,7 @@ const distEntry = path.join(distRoot, "/entry.js"); const buildStampPath = path.join(distRoot, ".buildstamp"); const srcRoot = path.join(cwd, "src"); const configFiles = [path.join(cwd, "tsconfig.json"), path.join(cwd, "package.json")]; +const gitWatchedPaths = ["src", "tsconfig.json", "package.json"]; const statMtime = (filePath) => { try { @@ -74,12 +75,70 @@ const findLatestMtime = (dirPath, shouldSkip) => { return latest; }; +const runGit = (args) => { + try { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + return (result.stdout ?? "").trim(); + } catch { + return null; + } +}; + +const resolveGitHead = () => { + const head = runGit(["rev-parse", "HEAD"]); + return head || null; +}; + +const hasDirtySourceTree = () => { + const output = runGit([ + "status", + "--porcelain", + "--untracked-files=normal", + "--", + ...gitWatchedPaths, + ]); + if (output === null) { + return null; + } + return output.length > 0; +}; + +const readBuildStamp = () => { + const mtime = statMtime(buildStampPath); + if (mtime == null) { + return { mtime: null, head: null }; + } + try { + const raw = fs.readFileSync(buildStampPath, "utf8").trim(); + if (!raw.startsWith("{")) { + return { mtime, head: null }; + } + const parsed = JSON.parse(raw); + const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null; + return { mtime, head }; + } catch { + return { mtime, head: null }; + } +}; + +const hasSourceMtimeChanged = (stampMtime) => { + const srcMtime = findLatestMtime(srcRoot, isExcludedSource); + return srcMtime != null && srcMtime > stampMtime; +}; + const shouldBuild = () => { if (env.OPENCLAW_FORCE_BUILD === "1") { return true; } - const stampMtime = statMtime(buildStampPath); - if (stampMtime == null) { + const stamp = readBuildStamp(); + if (stamp.mtime == null) { return true; } if (statMtime(distEntry) == null) { @@ -88,13 +147,29 @@ const shouldBuild = () => { for (const filePath of configFiles) { const mtime = statMtime(filePath); - if (mtime != null && mtime > stampMtime) { + if (mtime != null && mtime > stamp.mtime) { return true; } } - const srcMtime = findLatestMtime(srcRoot, isExcludedSource); - if (srcMtime != null && srcMtime > stampMtime) { + const currentHead = resolveGitHead(); + if (currentHead && !stamp.head) { + return hasSourceMtimeChanged(stamp.mtime); + } + if (currentHead && stamp.head && currentHead !== stamp.head) { + return hasSourceMtimeChanged(stamp.mtime); + } + if (currentHead) { + const dirty = hasDirtySourceTree(); + if (dirty === true) { + return true; + } + if (dirty === false) { + return false; + } + } + + if (hasSourceMtimeChanged(stamp.mtime)) { return true; } return false; @@ -125,7 +200,11 @@ const runNode = () => { const writeBuildStamp = () => { try { fs.mkdirSync(distRoot, { recursive: true }); - fs.writeFileSync(buildStampPath, `${Date.now()}\n`); + const stamp = { + builtAt: Date.now(), + head: resolveGitHead(), + }; + fs.writeFileSync(buildStampPath, `${JSON.stringify(stamp)}\n`); } catch (error) { // Best-effort stamp; still allow the runner to start. logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`); diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 207a28caef..a43c6d2e2b 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -144,6 +144,10 @@ describe("argv helpers", () => { expect(shouldMigrateState(["node", "openclaw", "status"])).toBe(false); expect(shouldMigrateState(["node", "openclaw", "health"])).toBe(false); expect(shouldMigrateState(["node", "openclaw", "sessions"])).toBe(false); + expect(shouldMigrateState(["node", "openclaw", "config", "get", "update"])).toBe(false); + expect(shouldMigrateState(["node", "openclaw", "config", "unset", "update"])).toBe(false); + expect(shouldMigrateState(["node", "openclaw", "models", "list"])).toBe(false); + expect(shouldMigrateState(["node", "openclaw", "models", "status"])).toBe(false); expect(shouldMigrateState(["node", "openclaw", "memory", "status"])).toBe(false); expect(shouldMigrateState(["node", "openclaw", "agent", "--message", "hi"])).toBe(false); expect(shouldMigrateState(["node", "openclaw", "agents", "list"])).toBe(true); @@ -152,6 +156,8 @@ describe("argv helpers", () => { it("reuses command path for migrate state decisions", () => { expect(shouldMigrateStateFromPath(["status"])).toBe(false); + expect(shouldMigrateStateFromPath(["config", "get"])).toBe(false); + expect(shouldMigrateStateFromPath(["models", "status"])).toBe(false); expect(shouldMigrateStateFromPath(["agents", "list"])).toBe(true); }); }); diff --git a/src/cli/argv.ts b/src/cli/argv.ts index d922e78638..1489cec4fc 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -155,6 +155,12 @@ export function shouldMigrateStateFromPath(path: string[]): boolean { if (primary === "health" || primary === "status" || primary === "sessions") { return false; } + if (primary === "config" && (secondary === "get" || secondary === "unset")) { + return false; + } + if (primary === "models" && (secondary === "list" || secondary === "status")) { + return false; + } if (primary === "memory" && secondary === "status") { return false; } diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts new file mode 100644 index 0000000000..d597f7d192 --- /dev/null +++ b/src/cli/program/config-guard.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadAndMaybeMigrateDoctorConfigMock = vi.hoisted(() => vi.fn()); +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../commands/doctor-config-flow.js", () => ({ + loadAndMaybeMigrateDoctorConfig: loadAndMaybeMigrateDoctorConfigMock, +})); + +vi.mock("../../config/config.js", () => ({ + readConfigFileSnapshot: readConfigFileSnapshotMock, +})); + +function makeSnapshot() { + return { + exists: false, + valid: true, + issues: [], + legacyIssues: [], + path: "/tmp/openclaw.json", + }; +} + +function makeRuntime() { + return { + error: vi.fn(), + exit: vi.fn(), + }; +} + +describe("ensureConfigReady", () => { + beforeEach(() => { + vi.clearAllMocks(); + readConfigFileSnapshotMock.mockResolvedValue(makeSnapshot()); + }); + + it("skips doctor flow for read-only fast path commands", async () => { + vi.resetModules(); + const { ensureConfigReady } = await import("./config-guard.js"); + await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["status"] }); + expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled(); + }); + + it("runs doctor flow for commands that may mutate state", async () => { + vi.resetModules(); + const { ensureConfigReady } = await import("./config-guard.js"); + await ensureConfigReady({ runtime: makeRuntime() as never, commandPath: ["message"] }); + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index 7737de03cc..0c15b8f121 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -3,6 +3,7 @@ import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-fl import { readConfigFileSnapshot } from "../../config/config.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; import { shortenHomePath } from "../../utils.js"; +import { shouldMigrateStateFromPath } from "../argv.js"; import { formatCliCommand } from "../command-format.js"; const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]); @@ -28,7 +29,8 @@ export async function ensureConfigReady(params: { runtime: RuntimeEnv; commandPath?: string[]; }): Promise { - if (!didRunDoctorConfigFlow) { + const commandPath = params.commandPath ?? []; + if (!didRunDoctorConfigFlow && shouldMigrateStateFromPath(commandPath)) { didRunDoctorConfigFlow = true; await loadAndMaybeMigrateDoctorConfig({ options: { nonInteractive: true }, @@ -37,8 +39,8 @@ export async function ensureConfigReady(params: { } const snapshot = await readConfigFileSnapshot(); - const commandName = params.commandPath?.[0]; - const subcommandName = params.commandPath?.[1]; + const commandName = commandPath[0]; + const subcommandName = commandPath[1]; const allowInvalid = commandName ? ALLOWED_INVALID_COMMANDS.has(commandName) || (commandName === "gateway" && diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index c0d8e9ad9b..866f35fb55 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -103,6 +103,34 @@ function getCommandPositionals(argv: string[]): string[] { return out; } +function getFlagValues(argv: string[], name: string): string[] | null { + const values: string[] = []; + const args = argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg || arg === "--") { + break; + } + if (arg === name) { + const next = args[i + 1]; + if (!next || next === "--" || next.startsWith("-")) { + return null; + } + values.push(next); + i += 1; + continue; + } + if (arg.startsWith(`${name}=`)) { + const value = arg.slice(name.length + 1).trim(); + if (!value) { + return null; + } + values.push(value); + } + } + return values; +} + const routeConfigGet: RouteSpec = { match: (path) => path[0] === "config" && path[1] === "get", run: async (argv) => { @@ -132,6 +160,80 @@ const routeConfigUnset: RouteSpec = { }, }; +const routeModelsList: RouteSpec = { + match: (path) => path[0] === "models" && path[1] === "list", + run: async (argv) => { + const provider = getFlagValue(argv, "--provider"); + if (provider === null) { + return false; + } + const all = hasFlag(argv, "--all"); + const local = hasFlag(argv, "--local"); + const json = hasFlag(argv, "--json"); + const plain = hasFlag(argv, "--plain"); + const { modelsListCommand } = await import("../../commands/models.js"); + await modelsListCommand({ all, local, provider, json, plain }, defaultRuntime); + return true; + }, +}; + +const routeModelsStatus: RouteSpec = { + match: (path) => path[0] === "models" && path[1] === "status", + run: async (argv) => { + const probeProvider = getFlagValue(argv, "--probe-provider"); + if (probeProvider === null) { + return false; + } + const probeTimeout = getFlagValue(argv, "--probe-timeout"); + if (probeTimeout === null) { + return false; + } + const probeConcurrency = getFlagValue(argv, "--probe-concurrency"); + if (probeConcurrency === null) { + return false; + } + const probeMaxTokens = getFlagValue(argv, "--probe-max-tokens"); + if (probeMaxTokens === null) { + return false; + } + const agent = getFlagValue(argv, "--agent"); + if (agent === null) { + return false; + } + const probeProfileValues = getFlagValues(argv, "--probe-profile"); + if (probeProfileValues === null) { + return false; + } + const probeProfile = + probeProfileValues.length === 0 + ? undefined + : probeProfileValues.length === 1 + ? probeProfileValues[0] + : probeProfileValues; + const json = hasFlag(argv, "--json"); + const plain = hasFlag(argv, "--plain"); + const check = hasFlag(argv, "--check"); + const probe = hasFlag(argv, "--probe"); + const { modelsStatusCommand } = await import("../../commands/models.js"); + await modelsStatusCommand( + { + json, + plain, + check, + probe, + probeProvider, + probeProfile, + probeTimeout, + probeConcurrency, + probeMaxTokens, + agent, + }, + defaultRuntime, + ); + return true; + }, +}; + const routes: RouteSpec[] = [ routeHealth, routeStatus, @@ -140,6 +242,8 @@ const routes: RouteSpec[] = [ routeMemoryStatus, routeConfigGet, routeConfigUnset, + routeModelsList, + routeModelsStatus, ]; export function findRoutedCommand(path: string[]): RouteSpec | null { diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index fa07ef2d50..c86071f7d8 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { rewriteUpdateFlagArgv, + shouldEnsureCliPath, shouldRegisterPrimarySubcommand, shouldSkipPluginCommandRegistration, } from "./run-main.js"; @@ -71,6 +72,16 @@ describe("shouldSkipPluginCommandRegistration", () => { ).toBe(true); }); + it("skips plugin registration for builtin command runs", () => { + expect( + shouldSkipPluginCommandRegistration({ + argv: ["node", "openclaw", "sessions", "--json"], + primary: "sessions", + hasBuiltinPrimary: true, + }), + ).toBe(true); + }); + it("keeps plugin registration for non-builtin help", () => { expect( shouldSkipPluginCommandRegistration({ @@ -80,4 +91,33 @@ describe("shouldSkipPluginCommandRegistration", () => { }), ).toBe(false); }); + + it("keeps plugin registration for non-builtin command runs", () => { + expect( + shouldSkipPluginCommandRegistration({ + argv: ["node", "openclaw", "voicecall", "status"], + primary: "voicecall", + hasBuiltinPrimary: false, + }), + ).toBe(false); + }); +}); + +describe("shouldEnsureCliPath", () => { + it("skips path bootstrap for help/version invocations", () => { + expect(shouldEnsureCliPath(["node", "openclaw", "--help"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "-V"])).toBe(false); + }); + + it("skips path bootstrap for read-only fast paths", () => { + expect(shouldEnsureCliPath(["node", "openclaw", "status"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "sessions", "--json"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "config", "get", "update"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "models", "status", "--json"])).toBe(false); + }); + + it("keeps path bootstrap for mutating or unknown commands", () => { + expect(shouldEnsureCliPath(["node", "openclaw", "message", "send"])).toBe(true); + expect(shouldEnsureCliPath(["node", "openclaw", "voicecall", "status"])).toBe(true); + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 4cc9bf4fbe..d90eda95b7 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -10,7 +10,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; -import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js"; +import { getCommandPath, getPrimaryCommand, hasHelpOrVersion } from "./argv.js"; import { tryRouteCli } from "./route.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { @@ -33,20 +33,42 @@ export function shouldSkipPluginCommandRegistration(params: { primary: string | null; hasBuiltinPrimary: boolean; }): boolean { - if (!hasHelpOrVersion(params.argv)) { - return false; - } - if (!params.primary) { + if (params.hasBuiltinPrimary) { return true; } - return params.hasBuiltinPrimary; + if (!params.primary) { + return hasHelpOrVersion(params.argv); + } + return false; +} + +export function shouldEnsureCliPath(argv: string[]): boolean { + if (hasHelpOrVersion(argv)) { + return false; + } + const [primary, secondary] = getCommandPath(argv, 2); + if (!primary) { + return true; + } + if (primary === "status" || primary === "health" || primary === "sessions") { + return false; + } + if (primary === "config" && (secondary === "get" || secondary === "unset")) { + return false; + } + if (primary === "models" && (secondary === "list" || secondary === "status")) { + return false; + } + return true; } export async function runCli(argv: string[] = process.argv) { const normalizedArgv = stripWindowsNodeExec(argv); loadDotEnv({ quiet: true }); normalizeEnv(); - ensureOpenClawCliOnPath(); + if (shouldEnsureCliPath(normalizedArgv)) { + ensureOpenClawCliOnPath(); + } // Enforce the minimum supported runtime before doing any work. assertSupportedRuntime();