diff --git a/CHANGELOG.md b/CHANGELOG.md index daf5628dc..487269375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.clawd.bot - Doctor: warn when gateway.mode is unset with configure/config guidance. - OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416) - Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat. +- Logs: align rolling log filenames with local time and fall back to latest file when today's log is missing. (#1343) - Models: inherit session model overrides in thread/topic sessions (Telegram topics, Slack/Discord threads). (#1376) - macOS: keep local auto bind loopback-first; only use tailnet when bind=tailnet. - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 0feb9656c..3c36e8bf5 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -17,6 +17,7 @@ Clawdbot has two log “surfaces”: ## File-based logger - Default rolling log file is under `/tmp/clawdbot/` (one file per day): `clawdbot-YYYY-MM-DD.log` + - Date uses the gateway host's local timezone. - The log file path and level can be configured via `~/.clawdbot/clawdbot.json`: - `logging.file` - `logging.level` diff --git a/docs/logging.md b/docs/logging.md index 74774866b..ad53c1164 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -22,6 +22,8 @@ By default, the Gateway writes a rolling log file under: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` +The date uses the gateway host's local timezone. + You can override this in `~/.clawdbot/clawdbot.json`: ```json diff --git a/src/gateway/server-methods/logs.test.ts b/src/gateway/server-methods/logs.test.ts new file mode 100644 index 000000000..f18c24e87 --- /dev/null +++ b/src/gateway/server-methods/logs.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { resetLogger, setLoggerOverride } from "../../logging.js"; +import { logsHandlers } from "./logs.js"; + +const noop = () => false; + +describe("logs.tail", () => { + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + }); + + it("falls back to latest rolling log file when today is missing", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-logs-")); + const older = path.join(tempDir, "clawdbot-2026-01-20.log"); + const newer = path.join(tempDir, "clawdbot-2026-01-21.log"); + + await fs.writeFile(older, '{"msg":"old"}\n'); + await fs.writeFile(newer, '{"msg":"new"}\n'); + await fs.utimes(older, new Date(0), new Date(0)); + await fs.utimes(newer, new Date(), new Date()); + + setLoggerOverride({ file: path.join(tempDir, "clawdbot-2026-01-22.log") }); + + const respond = vi.fn(); + await logsHandlers["logs.tail"]({ + params: {}, + respond, + context: {} as unknown as Parameters<(typeof logsHandlers)["logs.tail"]>[0]["context"], + client: null, + req: { id: "req-1", type: "req", method: "logs.tail" }, + isWebchatConnect: noop, + }); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + file: newer, + lines: ['{"msg":"new"}'], + }), + undefined, + ); + + await fs.rm(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/src/gateway/server-methods/logs.ts b/src/gateway/server-methods/logs.ts index dff1bee6f..ce45a276a 100644 --- a/src/gateway/server-methods/logs.ts +++ b/src/gateway/server-methods/logs.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import path from "node:path"; import { getResolvedLoggerSettings } from "../../logging.js"; import { ErrorCodes, @@ -12,11 +13,40 @@ const DEFAULT_LIMIT = 500; const DEFAULT_MAX_BYTES = 250_000; const MAX_LIMIT = 5000; const MAX_BYTES = 1_000_000; +const ROLLING_LOG_RE = /^clawdbot-\d{4}-\d{2}-\d{2}\.log$/; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } +function isRollingLogFile(file: string): boolean { + return ROLLING_LOG_RE.test(path.basename(file)); +} + +async function resolveLogFile(file: string): Promise { + const stat = await fs.stat(file).catch(() => null); + if (stat) return file; + if (!isRollingLogFile(file)) return file; + + const dir = path.dirname(file); + const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null); + if (!entries) return file; + + const candidates = await Promise.all( + entries + .filter((entry) => entry.isFile() && ROLLING_LOG_RE.test(entry.name)) + .map(async (entry) => { + const fullPath = path.join(dir, entry.name); + const fileStat = await fs.stat(fullPath).catch(() => null); + return fileStat ? { path: fullPath, mtimeMs: fileStat.mtimeMs } : null; + }), + ); + const sorted = candidates + .filter((entry): entry is NonNullable => Boolean(entry)) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + return sorted[0]?.path ?? file; +} + async function readLogSlice(params: { file: string; cursor?: number; @@ -126,8 +156,9 @@ export const logsHandlers: GatewayRequestHandlers = { } const p = params as { cursor?: number; limit?: number; maxBytes?: number }; - const file = getResolvedLoggerSettings().file; + const configuredFile = getResolvedLoggerSettings().file; try { + const file = await resolveLogFile(configuredFile); const result = await readLogSlice({ file, cursor: p.cursor, diff --git a/src/logger.test.ts b/src/logger.test.ts index 6dea8d503..d569b6610 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -70,7 +70,7 @@ describe("logger helpers", () => { it("uses daily rolling default log file and prunes old ones", () => { resetLogger(); setLoggerOverride({}); // force defaults regardless of user config - const today = new Date().toISOString().slice(0, 10); + const today = localDateString(new Date()); const todayPath = path.join(DEFAULT_LOG_DIR, `clawdbot-${today}.log`); // create an old file to be pruned @@ -103,3 +103,10 @@ function cleanup(file: string) { // ignore } } + +function localDateString(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/src/logging/logger.ts b/src/logging/logger.ts index a8dc784a4..d1c740212 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -197,8 +197,15 @@ export function registerLogTransport(transport: LogTransport): () => void { }; } +function formatLocalDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + function defaultRollingPathForToday(): string { - const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const today = formatLocalDate(new Date()); return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`); }