mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 08:44:46 +00:00
fix(cli): improve config startup progress
This commit is contained in:
@@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: keep progress drafts visible for message-tool-only guild replies under the default coding tool profile. Fixes #82747. Thanks @eliranwong.
|
||||
- CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically.
|
||||
- Diagnostics/usage/voice-call: treat explicit zero and non-finite limits as empty results and reject invalid voice-call numeric CLI flags. Fixes #82646, #82650, #82651, and #82653. (#82679) Thanks @leno23.
|
||||
- CLI/config: avoid redundant startup config/plugin checks for the guided `openclaw config` flow and show progress while source checkout CLI artifacts build or load.
|
||||
- Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518.
|
||||
- Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths.
|
||||
- Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145.
|
||||
|
||||
@@ -682,10 +682,88 @@ const logRunner = (message, deps) => {
|
||||
return;
|
||||
}
|
||||
const line = `[openclaw] ${message}\n`;
|
||||
deps.runNodeProgress?.clearLine();
|
||||
deps.stderr.write(line);
|
||||
deps.runNodeProgress?.render();
|
||||
deps.outputTee?.write(line);
|
||||
};
|
||||
|
||||
const RUN_NODE_PROGRESS_FRAMES = ["-", "\\", "|", "/"];
|
||||
|
||||
const shouldUseRunNodeProgress = (deps) =>
|
||||
deps.stderr?.isTTY === true &&
|
||||
deps.env.OPENCLAW_RUNNER_PROGRESS !== "0" &&
|
||||
deps.env.CI !== "true" &&
|
||||
!deps.outputTee;
|
||||
|
||||
const createRunNodeProgress = (label, deps) => {
|
||||
if (!shouldUseRunNodeProgress(deps)) {
|
||||
return null;
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
let frameIndex = 0;
|
||||
let active = true;
|
||||
let visible = false;
|
||||
|
||||
const clearLine = () => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
deps.stderr.write("\r\x1b[2K");
|
||||
visible = false;
|
||||
};
|
||||
const render = () => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||||
const frame = RUN_NODE_PROGRESS_FRAMES[frameIndex % RUN_NODE_PROGRESS_FRAMES.length];
|
||||
frameIndex += 1;
|
||||
deps.stderr.write(`\r[openclaw] ${frame} ${label} (${elapsedSeconds}s)`);
|
||||
visible = true;
|
||||
};
|
||||
const timer = setInterval(render, 120);
|
||||
timer.unref?.();
|
||||
render();
|
||||
|
||||
return {
|
||||
clearLine,
|
||||
render,
|
||||
stop() {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
clearLine();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const withRunNodeProgress = async (deps, label, callback) => {
|
||||
const previousProgress = deps.runNodeProgress;
|
||||
const progress = createRunNodeProgress(label, deps);
|
||||
if (progress) {
|
||||
deps.runNodeProgress = progress;
|
||||
}
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
if (progress) {
|
||||
progress.stop();
|
||||
deps.runNodeProgress = previousProgress;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const writeRunnerStream = (deps, stream, chunk) => {
|
||||
deps.runNodeProgress?.clearLine();
|
||||
stream.write(chunk);
|
||||
deps.runNodeProgress?.render();
|
||||
};
|
||||
|
||||
const shouldPipeSpawnedOutput = (deps) => Boolean(deps.outputTee || deps.runNodeProgress);
|
||||
|
||||
const sanitizeCpuProfileNamePart = (value) => {
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
@@ -810,7 +888,7 @@ const runOpenClaw = async (deps) => {
|
||||
};
|
||||
|
||||
const pipeSpawnedOutput = (childProcess, deps) => {
|
||||
if (!deps.outputTee) {
|
||||
if (!shouldPipeSpawnedOutput(deps)) {
|
||||
return;
|
||||
}
|
||||
const stderrFilter =
|
||||
@@ -818,16 +896,18 @@ const pipeSpawnedOutput = (childProcess, deps) => {
|
||||
? createSyncIoTraceStderrFilter(deps)
|
||||
: null;
|
||||
childProcess.stdout?.on("data", (chunk) => {
|
||||
deps.stdout.write(chunk);
|
||||
deps.outputTee.write(chunk);
|
||||
writeRunnerStream(deps, deps.stdout, chunk);
|
||||
deps.outputTee?.write(chunk);
|
||||
});
|
||||
childProcess.stderr?.on("data", (chunk) => {
|
||||
deps.runNodeProgress?.clearLine();
|
||||
if (stderrFilter) {
|
||||
stderrFilter.write(chunk);
|
||||
} else {
|
||||
deps.stderr.write(chunk);
|
||||
}
|
||||
deps.outputTee.write(chunk);
|
||||
deps.runNodeProgress?.render();
|
||||
deps.outputTee?.write(chunk);
|
||||
});
|
||||
childProcess.stderr?.on("end", () => {
|
||||
stderrFilter?.flush();
|
||||
@@ -1253,36 +1333,45 @@ export async function runNodeMain(params = {}) {
|
||||
);
|
||||
logRunner("Building bundled plugin assets.", deps);
|
||||
const buildCmd = deps.execPath;
|
||||
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(assetBuild, deps);
|
||||
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
|
||||
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
|
||||
if (assetBuildInterruptedExitCode !== null) {
|
||||
return assetBuildInterruptedExitCode;
|
||||
}
|
||||
if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) {
|
||||
return assetBuildRes.exitCode;
|
||||
}
|
||||
const compileExitCode = await withRunNodeProgress(
|
||||
deps,
|
||||
"Building local CLI artifacts",
|
||||
async () => {
|
||||
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(assetBuild, deps);
|
||||
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
|
||||
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
|
||||
if (assetBuildInterruptedExitCode !== null) {
|
||||
return assetBuildInterruptedExitCode;
|
||||
}
|
||||
if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) {
|
||||
return assetBuildRes.exitCode;
|
||||
}
|
||||
|
||||
const buildArgs = compilerArgs;
|
||||
const build = deps.spawn(buildCmd, buildArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(build, deps);
|
||||
const build = deps.spawn(buildCmd, compilerArgs, {
|
||||
cwd: deps.cwd,
|
||||
env: deps.env,
|
||||
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
pipeSpawnedOutput(build, deps);
|
||||
|
||||
const buildRes = await waitForSpawnedProcess(build, deps);
|
||||
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
|
||||
if (interruptedExitCode !== null) {
|
||||
return interruptedExitCode;
|
||||
}
|
||||
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
|
||||
return buildRes.exitCode;
|
||||
const buildRes = await waitForSpawnedProcess(build, deps);
|
||||
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
|
||||
if (interruptedExitCode !== null) {
|
||||
return interruptedExitCode;
|
||||
}
|
||||
if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) {
|
||||
return buildRes.exitCode;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
);
|
||||
if (compileExitCode !== 0) {
|
||||
return compileExitCode;
|
||||
}
|
||||
if (!(await syncRuntimeArtifacts(deps))) {
|
||||
return 1;
|
||||
|
||||
@@ -105,6 +105,11 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
policy: { loadPlugins: "never" },
|
||||
},
|
||||
{ commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
|
||||
{
|
||||
commandPath: ["config"],
|
||||
exact: true,
|
||||
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
|
||||
},
|
||||
{
|
||||
commandPath: ["migrate"],
|
||||
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
|
||||
|
||||
@@ -189,6 +189,15 @@ describe("command-path-policy", () => {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
});
|
||||
expectResolvedPolicy(["config"], {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
networkProxy: "bypass",
|
||||
});
|
||||
expectResolvedPolicy(["config", "set"], {
|
||||
loadPlugins: "never",
|
||||
networkProxy: "bypass",
|
||||
});
|
||||
expectResolvedPolicy(["config", "validate"], {
|
||||
bypassConfigGuard: true,
|
||||
loadPlugins: "never",
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
describe("command-startup-policy", () => {
|
||||
it("matches config guard bypass commands", () => {
|
||||
expect(shouldBypassConfigGuardForCommandPath(["backup", "create"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["config", "set"])).toBe(false);
|
||||
expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ describe("registerPreActionHooks", () => {
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
const config = program.command("config");
|
||||
config.option("--section <section>");
|
||||
setCommandJsonMode(config.command("set"), "parse-only")
|
||||
.argument("<path>")
|
||||
.argument("<value>")
|
||||
@@ -313,6 +314,26 @@ describe("registerPreActionHooks", () => {
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets bare config own config validation and plugin loading", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["config"],
|
||||
processArgv: ["node", "openclaw", "config"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets guided config sections own config validation and plugin loading", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["config"],
|
||||
processArgv: ["node", "openclaw", "config", "--section", "models"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only allows invalid config for explicit official recovery reinstall requests", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["plugins", "install", "@openclaw/discord"],
|
||||
|
||||
@@ -75,6 +75,10 @@ function isBareParentDefaultHelpInvocation(actionCommand: Command, argv: string[
|
||||
return primary === actionCommand.name() || actionCommand.aliases().includes(primary);
|
||||
}
|
||||
|
||||
function isGuidedConfigAction(actionCommand: Command): boolean {
|
||||
return actionCommand.name() === "config" && actionCommand.parent?.parent === undefined;
|
||||
}
|
||||
|
||||
export function registerPreActionHooks(program: Command, programVersion: string) {
|
||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||
setProcessTitleForCommand(actionCommand);
|
||||
@@ -101,7 +105,7 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
if (!verbose) {
|
||||
process.env.NODE_NO_WARNINGS ??= "1";
|
||||
}
|
||||
if (shouldBypassConfigGuardForCommandPath(commandPath)) {
|
||||
if (shouldBypassConfigGuardForCommandPath(commandPath) || isGuidedConfigAction(actionCommand)) {
|
||||
return;
|
||||
}
|
||||
await ensureCliExecutionBootstrap({
|
||||
|
||||
@@ -292,6 +292,24 @@ describe("runCli exit behavior", () => {
|
||||
expect(disposeRegisteredAgentHarnessesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows the standard spinner while loading the full CLI", async () => {
|
||||
tryRouteCliMock.mockResolvedValueOnce(false);
|
||||
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
|
||||
buildProgramMock.mockReturnValueOnce({
|
||||
commands: [{ name: () => "config", aliases: () => [] }],
|
||||
parseAsync,
|
||||
});
|
||||
|
||||
await runCli(["node", "openclaw", "config"]);
|
||||
|
||||
expect(createCliProgressMock).toHaveBeenCalledWith({
|
||||
label: "Loading OpenClaw CLI…",
|
||||
indeterminate: true,
|
||||
delayMs: 0,
|
||||
});
|
||||
expect(progressDoneMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pauses non-tty stdin after full CLI command completion", async () => {
|
||||
tryRouteCliMock.mockResolvedValueOnce(false);
|
||||
const parseAsync = vi.fn().mockResolvedValueOnce(undefined);
|
||||
|
||||
@@ -633,7 +633,6 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
label: "Loading OpenClaw CLI…",
|
||||
indeterminate: true,
|
||||
delayMs: 0,
|
||||
fallback: "none",
|
||||
});
|
||||
let startupProgressStopped = false;
|
||||
const stopStartupProgress = () => {
|
||||
|
||||
@@ -1397,6 +1397,48 @@ describe("run-node script", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows tty progress while rebuilding source-checkout artifacts", async () => {
|
||||
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
|
||||
await setupTrackedProject(tmp, {
|
||||
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE],
|
||||
buildPaths: [DIST_ENTRY, BUILD_STAMP],
|
||||
});
|
||||
const { spawn, spawnSync } = createSpawnRecorder();
|
||||
const stderrChunks: string[] = [];
|
||||
const stderr = {
|
||||
isTTY: true,
|
||||
write: vi.fn((chunk: string) => {
|
||||
stderrChunks.push(String(chunk));
|
||||
return true;
|
||||
}),
|
||||
} as unknown as NodeJS.WriteStream;
|
||||
const stdout = {
|
||||
write: vi.fn(() => true),
|
||||
} as unknown as NodeJS.WriteStream;
|
||||
|
||||
const exitCode = await runNodeMain({
|
||||
cwd: tmp,
|
||||
args: ["status"],
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_FORCE_BUILD: "1",
|
||||
},
|
||||
spawn,
|
||||
spawnSync,
|
||||
stderr,
|
||||
stdout,
|
||||
runRuntimePostBuild: async () => {},
|
||||
execPath: process.execPath,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
const stderrText = stderrChunks.join("");
|
||||
expect(stderrText).toContain("Building local CLI artifacts");
|
||||
expect(stderrText).toContain("\x1b[2K");
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds when git HEAD changes even if source mtimes do not exceed the old build stamp", async () => {
|
||||
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
|
||||
await setupTrackedProject(tmp, {
|
||||
|
||||
Reference in New Issue
Block a user