perf(cli): reduce read-only startup overhead

This commit is contained in:
Peter Steinberger
2026-02-14 01:18:20 +00:00
parent 54a242eaad
commit f86840f4df
8 changed files with 326 additions and 17 deletions

View File

@@ -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"}`);

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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<void> {
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" &&

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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();