fix(cli): preserve gateway request errors in json mode

This commit is contained in:
Vincent Koc
2026-06-16 01:51:41 +02:00
parent 52d9d16e1b
commit b41c0b6746
6 changed files with 356 additions and 114 deletions
+33
View File
@@ -13,6 +13,7 @@ type DiscoveredBeacon = Awaited<
>[number];
const callGateway = vi.fn<(opts: unknown) => Promise<{ ok: true }>>(async () => ({ ok: true }));
const formatGatewayClientRequestErrorJson = vi.fn();
const formatGatewayTransportErrorJson = vi.fn();
const startGatewayServer = vi.fn<
(port: number, opts?: unknown) => Promise<{ close: () => Promise<void> }>
@@ -55,6 +56,8 @@ vi.mock(
url: "ws://127.0.0.1:18789",
}),
callGateway: (opts: unknown) => callGateway(opts),
formatGatewayClientRequestErrorJson: (error: unknown) =>
formatGatewayClientRequestErrorJson(error),
formatGatewayTransportErrorJson: (error: unknown) => formatGatewayTransportErrorJson(error),
isGatewayCredentialsRequiredError: () => false,
randomIdempotencyKey: () => "rk_test",
@@ -156,6 +159,8 @@ describe("gateway-cli coverage", () => {
startGatewayServer.mockClear();
inspectPortUsage.mockClear();
formatPortDiagnostics.mockClear();
formatGatewayClientRequestErrorJson.mockReset();
formatGatewayClientRequestErrorJson.mockReturnValue(null);
formatGatewayTransportErrorJson.mockReset();
formatGatewayTransportErrorJson.mockReturnValue(null);
});
@@ -232,6 +237,34 @@ describe("gateway-cli coverage", () => {
expect(runtimeErrors.join("\n")).not.toContain("gateway closed");
});
it.each([
["call", ["gateway", "call", "skills.bins", "--json"]],
["usage cost", ["gateway", "usage-cost", "--json"]],
["stability", ["gateway", "stability", "--json"]],
])("writes JSON for gateway %s request failures in JSON mode", async (_label, args) => {
const error = Object.assign(new Error("unauthorized role: operator"), {
name: "GatewayClientRequestError",
gatewayCode: "INVALID_REQUEST",
});
const payload = {
ok: false,
error: {
type: "gateway_request_error",
code: "INVALID_REQUEST",
message: "unauthorized role: operator",
retryable: false,
},
};
callGateway.mockRejectedValueOnce(error);
formatGatewayClientRequestErrorJson.mockReturnValueOnce(payload);
await expectGatewayExit(args);
expect(formatGatewayClientRequestErrorJson).toHaveBeenCalledWith(error);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(payload);
expect(runtimeErrors.join("\n")).not.toContain("unauthorized role");
});
it("prints the latest stability bundle without calling Gateway", async () => {
callGateway.mockClear();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-cli-bundle-"));
+105 -88
View File
@@ -108,13 +108,15 @@ async function runGatewayCommand(
label?: string,
opts?: { json?: boolean },
) {
// JSON mode preserves structured gateway transport errors for automation callers.
// JSON mode preserves structured gateway errors for automation callers.
try {
await action();
} catch (err) {
if (opts?.json) {
const { formatGatewayTransportErrorJson } = await import("../../gateway/call.js");
const payload = formatGatewayTransportErrorJson(err);
const { formatGatewayClientRequestErrorJson, formatGatewayTransportErrorJson } =
await import("../../gateway/call.js");
const payload =
formatGatewayClientRequestErrorJson(err) ?? formatGatewayTransportErrorJson(err);
if (payload) {
defaultRuntime.writeJson(payload);
defaultRuntime.exit(1);
@@ -499,20 +501,24 @@ export function registerGatewayCli(program: Command) {
.argument("<method>", "Method name (health/status/system-presence/cron.*)")
.option("--params <json>", "JSON object string for params", "{}")
.action(async (method, opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, rpcOpts, params);
if (rpcOpts.json) {
await runGatewayCommand(
async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const params = JSON.parse(String(opts.params ?? "{}"));
const result = await callGatewayCli(method, rpcOpts, params);
if (rpcOpts.json) {
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
defaultRuntime.log(
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`,
);
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
defaultRuntime.log(
`${colorize(rich, theme.heading, "Gateway call")}: ${colorize(rich, theme.muted, String(method))}`,
);
defaultRuntime.writeJson(result);
}, "Gateway call failed");
},
"Gateway call failed",
{ json: Boolean(opts.json) },
);
}),
);
@@ -522,20 +528,24 @@ export function registerGatewayCli(program: Command) {
.description("Fetch usage cost summary from session logs")
.option("--days <days>", "Number of days to include", "30")
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", rpcOpts, { days });
if (rpcOpts.json) {
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
const summary = result as CostUsageSummary;
for (const line of await renderCostUsageSummaryAsync(summary, days, rich)) {
defaultRuntime.log(line);
}
}, "Gateway usage cost failed");
await runGatewayCommand(
async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const days = parseDaysOption(opts.days);
const result = await callGatewayCli("usage.cost", rpcOpts, { days });
if (rpcOpts.json) {
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
const summary = result as CostUsageSummary;
for (const line of await renderCostUsageSummaryAsync(summary, days, rich)) {
defaultRuntime.log(line);
}
},
"Gateway usage cost failed",
{ json: Boolean(opts.json) },
);
}),
);
@@ -608,71 +618,78 @@ export function registerGatewayCli(program: Command) {
.option("--export", "Write a shareable support diagnostics export", false)
.option("--output <path>", "Diagnostics export output .zip path")
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const { normalizeDiagnosticStabilityQuery, selectDiagnosticStabilitySnapshot } =
await import("../../logging/diagnostic-stability.js");
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const query = normalizeDiagnosticStabilityQuery(
{
limit: opts.limit,
sinceSeq: opts.sinceSeq,
type: opts.type,
},
{ defaultLimit: 25 },
);
const bundleTarget = normalizeStabilityBundleTarget(opts.bundle);
if (opts.export) {
await writeSupportExportFromCli({
json: rpcOpts.json,
output: opts.output,
stabilityBundle: bundleTarget ?? "latest",
rpc: rpcOpts,
});
return;
}
if (bundleTarget) {
const result = await readStabilityBundleTarget(bundleTarget);
if (result.status !== "found") {
throw new Error(formatBundleError(result));
}
const snapshot = selectDiagnosticStabilitySnapshot(result.bundle.snapshot, query);
if (rpcOpts.json) {
defaultRuntime.writeJson({
path: result.path,
mtimeMs: result.mtimeMs,
bundle: {
...result.bundle,
snapshot,
},
await runGatewayCommand(
async () => {
const { normalizeDiagnosticStabilityQuery, selectDiagnosticStabilitySnapshot } =
await import("../../logging/diagnostic-stability.js");
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const query = normalizeDiagnosticStabilityQuery(
{
limit: opts.limit,
sinceSeq: opts.sinceSeq,
type: opts.type,
},
{ defaultLimit: 25 },
);
const bundleTarget = normalizeStabilityBundleTarget(opts.bundle);
if (opts.export) {
await writeSupportExportFromCli({
json: rpcOpts.json,
output: opts.output,
stabilityBundle: bundleTarget ?? "latest",
rpc: rpcOpts,
});
return;
}
if (bundleTarget) {
const result = await readStabilityBundleTarget(bundleTarget);
if (result.status !== "found") {
throw new Error(formatBundleError(result));
}
const snapshot = selectDiagnosticStabilitySnapshot(result.bundle.snapshot, query);
if (rpcOpts.json) {
defaultRuntime.writeJson({
path: result.path,
mtimeMs: result.mtimeMs,
bundle: {
...result.bundle,
snapshot,
},
});
return;
}
const rich = isRich();
for (const line of renderStabilityBundleSummary({
bundle: result.bundle,
path: result.path,
rich,
snapshot,
})) {
defaultRuntime.log(line);
}
return;
}
const result = await callGatewayCli("diagnostics.stability", rpcOpts, {
limit: query.limit,
...(query.type ? { type: query.type } : {}),
...(query.sinceSeq !== undefined ? { sinceSeq: query.sinceSeq } : {}),
});
if (rpcOpts.json) {
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
for (const line of renderStabilityBundleSummary({
bundle: result.bundle,
path: result.path,
for (const line of renderStabilitySummary(
result as DiagnosticStabilitySnapshot,
rich,
snapshot,
})) {
)) {
defaultRuntime.log(line);
}
return;
}
const result = await callGatewayCli("diagnostics.stability", rpcOpts, {
limit: query.limit,
...(query.type ? { type: query.type } : {}),
...(query.sinceSeq !== undefined ? { sinceSeq: query.sinceSeq } : {}),
});
if (rpcOpts.json) {
defaultRuntime.writeJson(result);
return;
}
const rich = isRich();
for (const line of renderStabilitySummary(result as DiagnosticStabilitySnapshot, rich)) {
defaultRuntime.log(line);
}
}, "Gateway stability failed");
},
"Gateway stability failed",
{ json: Boolean(opts.json) },
);
}),
);