fix: preserve early failure diagnostics

This commit is contained in:
Gustavo Madeira Santana
2026-04-22 18:36:23 -04:00
parent 33f8a0e4f5
commit 43f5a3c1f7
4 changed files with 88 additions and 9 deletions

View File

@@ -9,6 +9,7 @@ import {
readDiagnosticStabilityBundleFileSync,
readLatestDiagnosticStabilityBundleSync,
resetDiagnosticStabilityBundleForTest,
writeDiagnosticStabilityBundleForFailureSync,
writeDiagnosticStabilityBundleSync,
type DiagnosticStabilityBundle,
} from "./diagnostic-stability-bundle.js";
@@ -110,6 +111,33 @@ describe("diagnostic stability bundles", () => {
expect(fs.existsSync(path.join(tempDir, "logs", "stability"))).toBe(false);
});
it("writes failure bundles even when the recorder snapshot is empty", () => {
const result = writeDiagnosticStabilityBundleForFailureSync(
"gateway.restart_startup_failed",
Object.assign(new Error("raw startup config payload"), { code: "ERR_CONFIG_PARSE" }),
{
stateDir: tempDir,
now: new Date("2026-04-22T12:00:00.000Z"),
},
);
expect(result.status).toBe("written");
const bundle = readBundle(result.status === "written" ? result.path : "");
const raw = fs.readFileSync(result.status === "written" ? result.path : "", "utf8");
expect(bundle).toMatchObject({
reason: "gateway.restart_startup_failed",
error: {
name: "Error",
code: "ERR_CONFIG_PARSE",
},
snapshot: {
count: 0,
events: [],
},
});
expect(raw).not.toContain("raw startup config payload");
});
it("registers a fatal hook only while installed", () => {
startDiagnosticStabilityRecorder();
emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" });

View File

@@ -365,7 +365,7 @@ export function writeDiagnosticStabilityBundleForFailureSync(
...options,
reason,
error,
includeEmpty: false,
includeEmpty: true,
});
if (result.status === "written") {
return {

View File

@@ -2,7 +2,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSZip from "jszip";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import {
resetDiagnosticStabilityBundleForTest,
@@ -549,4 +549,51 @@ describe("diagnostic support export", () => {
expect(combined).toContain("log-tail-read-failed");
expect(combined).toContain("sanitized log tail unavailable");
});
it("keeps writing when config stat fails", async () => {
const fakeToken = "sk-test-config-stat-secret-token-1234567890";
const configPath = path.join(tempDir, "openclaw.json");
const outputPath = path.join(tempDir, "support-failed-config-stat.zip");
fs.writeFileSync(configPath, "{}\n", "utf8");
const originalStatSync = fs.statSync.bind(fs);
const statSpy = vi.spyOn(fs, "statSync").mockImplementation((target, options) => {
if (target === configPath) {
throw new Error(`config stat failed with token ${fakeToken}`);
}
return originalStatSync(target, options as never);
});
try {
await writeDiagnosticSupportExport({
env: {
...process.env,
HOME: tempDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: tempDir,
},
stateDir: tempDir,
outputPath,
now: new Date("2026-04-22T12:00:03.000Z"),
readLogTail: async () => ({
file: path.join(tempDir, "logs", "openclaw.log"),
cursor: 0,
size: 0,
truncated: false,
reset: false,
lines: [],
}),
});
} finally {
statSpy.mockRestore();
}
const entries = await readZipTextEntries(outputPath);
const combined = Object.values(entries).join("\n");
expect(Object.keys(entries).toSorted()).toContain("config/shape.json");
expect(combined).not.toContain(fakeToken);
expect(combined).toContain('"parseOk": false');
expect(combined).toContain("config stat failed with token");
expect(combined).toContain("Attach this zip to the bug report");
});
});

View File

@@ -284,19 +284,22 @@ function configShapeReadFailure(params: {
return shape;
}
function isMissingPathError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("code" in error)) {
return false;
}
return error.code === "ENOENT" || error.code === "ENOTDIR";
}
function readConfigExport(options: {
configPath: string;
env: NodeJS.ProcessEnv;
stateDir: string;
}): ConfigExport {
const redactedConfigPath = redactPathForSupport(options.configPath, options);
const stat = fs.existsSync(options.configPath) ? fs.statSync(options.configPath) : null;
if (!stat) {
return {
shape: configShapeReadFailure({ configPath: redactedConfigPath, redaction: options }),
};
}
let stat: fs.Stats | undefined;
try {
stat = fs.statSync(options.configPath);
const parsed = parseConfigJson5(fs.readFileSync(options.configPath, "utf8"));
if (!parsed.ok) {
return {
@@ -313,12 +316,13 @@ function readConfigExport(options: {
sanitized: sanitizeConfigDetails(parsed.parsed, options),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
shape: configShapeReadFailure({
configPath: redactedConfigPath,
redaction: options,
stat,
error: error instanceof Error ? error.message : String(error),
error: !stat && isMissingPathError(error) ? undefined : errorMessage,
}),
};
}