diff --git a/CHANGELOG.md b/CHANGELOG.md index 271d0098aa6..c96eb423ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,18 @@ Docs: https://docs.openclaw.ai - Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek. - Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc. - Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini. +- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini. - Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini. - Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery. - Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd. - Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55. +- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini. +- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon. +- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro. +- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87. +- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu. +- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic. +- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo. - WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10. - WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib. - WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr. diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index e1eb6c5eb26..b7925bf812b 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -132,9 +132,8 @@ describe("logs cli", () => { it("formats local time in plain mode when localTime is true", () => { const utcTime = "2025-01-01T12:00:00.000Z"; const result = formatLogTimestamp(utcTime, "plain", true); - // Should be local time without 'Z' suffix - expect(result).not.toContain("Z"); - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + // Should be local time with explicit timezone offset (not 'Z' suffix). + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); // The exact time depends on timezone, but should be different from UTC expect(result).not.toBe(utcTime); }); diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 6c8222fa5cf..073bc03ddee 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -72,11 +72,25 @@ export function formatLogTimestamp( if (Number.isNaN(parsed.getTime())) { return value; } + + const formatLocalIsoWithOffset = (now: Date) => { + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const h = String(now.getHours()).padStart(2, "0"); + const m = String(now.getMinutes()).padStart(2, "0"); + const s = String(now.getSeconds()).padStart(2, "0"); + const ms = String(now.getMilliseconds()).padStart(3, "0"); + const tzOffset = now.getTimezoneOffset(); + const tzSign = tzOffset <= 0 ? "+" : "-"; + const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, "0"); + const tzMinutes = String(Math.abs(tzOffset) % 60).padStart(2, "0"); + return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`; + }; + let timeString: string; if (localTime) { - const tzoffset = parsed.getTimezoneOffset() * 60000; // offset in milliseconds - const localISOTime = new Date(parsed.getTime() - tzoffset).toISOString().slice(0, -1); - timeString = localISOTime; + timeString = formatLocalIsoWithOffset(parsed); } else { timeString = parsed.toISOString(); } diff --git a/src/logging/console-timestamp.test.ts b/src/logging/console-timestamp.test.ts index 5bc5aaf0083..a15183c70ca 100644 --- a/src/logging/console-timestamp.test.ts +++ b/src/logging/console-timestamp.test.ts @@ -1,31 +1,69 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { formatConsoleTimestamp } from "./console.js"; describe("formatConsoleTimestamp", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + function pad2(n: number) { + return String(n).padStart(2, "0"); + } + + function pad3(n: number) { + return String(n).padStart(3, "0"); + } + + function formatExpectedLocalIsoWithOffset(now: Date) { + const year = now.getFullYear(); + const month = pad2(now.getMonth() + 1); + const day = pad2(now.getDate()); + const h = pad2(now.getHours()); + const m = pad2(now.getMinutes()); + const s = pad2(now.getSeconds()); + const ms = pad3(now.getMilliseconds()); + const tzOffset = now.getTimezoneOffset(); + const tzSign = tzOffset <= 0 ? "+" : "-"; + const tzHours = pad2(Math.floor(Math.abs(tzOffset) / 60)); + const tzMinutes = pad2(Math.abs(tzOffset) % 60); + return `${year}-${month}-${day}T${h}:${m}:${s}.${ms}${tzSign}${tzHours}:${tzMinutes}`; + } + it("pretty style returns local HH:MM:SS", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z")); + const result = formatConsoleTimestamp("pretty"); - expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); - // Verify it uses local time, not UTC const now = new Date(); - const expectedHour = String(now.getHours()).padStart(2, "0"); - expect(result.slice(0, 2)).toBe(expectedHour); + expect(result).toBe( + `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`, + ); }); it("compact style returns local ISO-like timestamp with timezone offset", () => { const result = formatConsoleTimestamp("compact"); - // Should match: YYYY-MM-DDTHH:MM:SS.mmm+HH:MM or -HH:MM expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); - // Should NOT end with Z (UTC indicator) - expect(result).not.toMatch(/Z$/); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z")); + const now = new Date(); + expect(formatConsoleTimestamp("compact")).toBe(formatExpectedLocalIsoWithOffset(now)); }); it("json style returns local ISO-like timestamp with timezone offset", () => { const result = formatConsoleTimestamp("json"); expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); - expect(result).not.toMatch(/Z$/); + + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z")); + const now = new Date(); + expect(formatConsoleTimestamp("json")).toBe(formatExpectedLocalIsoWithOffset(now)); }); it("timestamp contains the correct local date components", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-17T18:01:02.345Z")); + const before = new Date(); const result = formatConsoleTimestamp("compact"); const after = new Date();