fix(logging): report unavailable log tails (#126481)

Only missing-path metadata failures remain empty successes. Operational filesystem errors now reach existing Gateway, UI, CLI, channel, and diagnostics error paths.\n\nCloses #126467
This commit is contained in:
Peter Steinberger
2026-08-19 17:00:06 -07:00
committed by GitHub
parent 13fd888281
commit c4c9f6d464
2 changed files with 60 additions and 4 deletions
+48
View File
@@ -6,6 +6,16 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { resetLogger, setLoggerOverride } from "../logging.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const metadataBoundaries = [
"configured stat",
"rolling readdir",
"candidate stat",
"final stat",
] as const;
const operationalErrorCodes = ["EACCES", "EIO", "EMFILE"] as const;
const operationalMetadataFailures = metadataBoundaries.flatMap((boundary) =>
operationalErrorCodes.map((code) => ({ boundary, code })),
);
const resolvedRedaction = { mode: "tools" as const, patterns: [/custom-secret-[a-z]+/g] };
type PositionalRead = (
@@ -123,6 +133,44 @@ describe("readConfiguredLogTail", () => {
expect(result.lines[0]?.trimEnd()).toBe("first-line-in-window");
});
it.each(operationalMetadataFailures)(
"rethrows $code from the $boundary boundary",
async ({ boundary, code }) => {
const tempDir = tempDirs.make("openclaw-log-tail-");
const configured = path.join(tempDir, "openclaw-2026-01-22.log");
const candidate = path.join(tempDir, "openclaw-2026-01-21.log");
const error = Object.assign(new Error(`${code} injected`), { code });
const realStat = fs.stat.bind(fs);
if (boundary === "candidate stat") {
await fs.writeFile(candidate, "candidate\n");
} else if (boundary !== "rolling readdir") {
await fs.writeFile(configured, "configured\n");
}
setLoggerOverride({ file: configured });
if (boundary === "configured stat") {
vi.spyOn(fs, "stat").mockRejectedValueOnce(error);
} else if (boundary === "rolling readdir") {
vi.spyOn(fs, "readdir").mockRejectedValueOnce(error);
} else if (boundary === "candidate stat") {
vi.spyOn(fs, "stat").mockImplementation(async (...args: Parameters<typeof fs.stat>) => {
if (String(args[0]) === candidate) {
throw error;
}
return realStat(...args);
});
} else {
vi.spyOn(fs, "stat")
.mockImplementationOnce((...args: Parameters<typeof fs.stat>) => realStat(...args))
.mockRejectedValueOnce(error);
}
const { readConfiguredLogTail } = await import("./log-tail.js");
await expect(readConfiguredLogTail()).rejects.toBe(error);
},
);
it("falls back only within the active profile's rolling log family", async () => {
const tempDir = tempDirs.make("openclaw-log-tail-");
const missing = path.join(tempDir, "openclaw-2026-01-22.log");
+12 -4
View File
@@ -1,6 +1,7 @@
// Log tail helpers read recent log lines with optional parsing and redaction.
import fs from "node:fs/promises";
import path from "node:path";
import { isMissingPathError } from "../infra/errno.js";
import { readFileWindowFully } from "../infra/file-read.js";
import { clamp } from "../utils.js";
import { isRollingLogFilePath, isSameRollingLogFileFamily } from "./log-file-path.js";
@@ -15,6 +16,13 @@ const DEFAULT_MAX_BYTES = 250_000;
const MAX_LIMIT = 5000;
const MAX_BYTES = 1_000_000;
function missingPathToNull(error: unknown): null {
if (!isMissingPathError(error)) {
throw error;
}
return null;
}
/** Payload returned to log-tail callers with cursor and truncation metadata. */
export type LogTailPayload = {
file: string;
@@ -32,7 +40,7 @@ type ParsedLogTailPayload = Omit<LogTailPayload, "lines"> & {
/** Resolves a rolling daily log path to the newest existing rolling log when needed. */
async function resolveLogFile(file: string, options?: { rolling?: boolean }): Promise<string> {
const stat = await fs.stat(file).catch(() => null);
const stat = await fs.stat(file).catch(missingPathToNull);
if (stat) {
return file;
}
@@ -41,7 +49,7 @@ async function resolveLogFile(file: string, options?: { rolling?: boolean }): Pr
}
const dir = path.dirname(file);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(missingPathToNull);
if (!entries) {
return file;
}
@@ -51,7 +59,7 @@ async function resolveLogFile(file: string, options?: { rolling?: boolean }): Pr
.filter((entry) => entry.isFile() && isSameRollingLogFileFamily(file, entry.name))
.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
const fileStat = await fs.stat(fullPath).catch(() => null);
const fileStat = await fs.stat(fullPath).catch(missingPathToNull);
return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null;
}),
);
@@ -68,7 +76,7 @@ async function readLogSlice(params: {
maxBytes: number;
filter?: (line: string) => boolean;
}): Promise<Omit<LogTailPayload, "file">> {
const stat = await fs.stat(params.file).catch(() => null);
const stat = await fs.stat(params.file).catch(missingPathToNull);
if (!stat) {
return {
cursor: 0,