mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
perf(cli): reduce read-only startup overhead
This commit is contained in:
@@ -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"}`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
50
src/cli/program/config-guard.test.ts
Normal file
50
src/cli/program/config-guard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user