From a732208d45040bcac5c657196264654434f5bac3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 02:11:33 -0700 Subject: [PATCH] fix(qqbot): avoid log export filename collisions (#77765) * fix(qqbot): avoid log export filename collisions * test(qqbot): narrow log export result assertions --- .../commands/builtin/log-helpers.test.ts | 61 +++++++++++++++++++ .../engine/commands/builtin/log-helpers.ts | 28 ++++++++- 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts new file mode 100644 index 00000000000..772a38ac04c --- /dev/null +++ b/extensions/qqbot/src/engine/commands/builtin/log-helpers.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const platformMock = await vi.hoisted(async () => { + const fs = await import("node:fs"); + const path = await import("node:path"); + return { + fs, + homeDir: "", + path, + }; +}); + +vi.mock("../../utils/platform.js", () => ({ + getHomeDir: () => platformMock.homeDir, + getQQBotDataDir: (...subPaths: string[]) => { + const dir = platformMock.path.join(platformMock.homeDir, ".openclaw", "qqbot", ...subPaths); + platformMock.fs.mkdirSync(dir, { recursive: true }); + return dir; + }, + isWindows: () => false, +})); + +import { buildBotLogsResult } from "./log-helpers.js"; + +describe("buildBotLogsResult", () => { + let tempHome: string; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-logs-")); + platformMock.homeDir = tempHome; + }); + + afterEach(() => { + vi.useRealTimers(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it("suffixes same-second log exports instead of overwriting", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z")); + const logDir = path.join(tempHome, ".openclaw", "logs"); + fs.mkdirSync(logDir, { recursive: true }); + fs.writeFileSync(path.join(logDir, "gateway.log"), "line 1\nline 2\n", "utf8"); + + const first = buildBotLogsResult(); + const second = buildBotLogsResult(); + + expect(typeof first).toBe("object"); + expect(typeof second).toBe("object"); + if (!first || !second || typeof first === "string" || typeof second === "string") { + throw new Error("expected file upload results"); + } + expect(path.basename(first.filePath)).toBe("bot-logs-2026-05-05T10-11-12.txt"); + expect(path.basename(second.filePath)).toBe("bot-logs-2026-05-05T10-11-12-2.txt"); + expect(fs.readFileSync(first.filePath, "utf8")).toContain("line 1"); + expect(fs.readFileSync(second.filePath, "utf8")).toContain("line 2"); + }); +}); diff --git a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts index 940db1f5b00..fd039fbaaea 100644 --- a/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts +++ b/extensions/qqbot/src/engine/commands/builtin/log-helpers.ts @@ -128,6 +128,28 @@ type LogCandidate = { mtimeMs: number; }; +function addCollisionSuffix(filePath: string, suffix: number): string { + const ext = path.extname(filePath); + const baseName = path.basename(filePath, ext); + return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`); +} + +function writeNewTextFileSync(filePath: string, contents: string): string { + for (let suffix = 1; suffix <= 100; suffix++) { + const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix); + try { + fs.writeFileSync(candidate, contents, { encoding: "utf8", flag: "wx" }); + return candidate; + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") { + continue; + } + throw error; + } + } + throw new Error(`Could not find an unused log export filename near ${filePath}`); +} + function collectRecentLogFiles(logDirs: string[]): LogCandidate[] { const candidates: LogCandidate[] = []; const dedupe = new Set(); @@ -303,8 +325,10 @@ export function buildBotLogsResult(): SlashCommandResult { const tmpDir = getQQBotDataDir("downloads"); const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`); - fs.writeFileSync(tmpFile, lines.join("\n"), "utf8"); + const tmpFile = writeNewTextFileSync( + path.join(tmpDir, `bot-logs-${timestamp}.txt`), + lines.join("\n"), + ); const fileCount = recentFiles.length; const topSources = Array.from(new Set(recentFiles.map((item) => item.sourceDir))).slice(0, 3);