From d2f69ecc3b2776a4d9085e6df2c818aa38522f34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 08:46:51 -0400 Subject: [PATCH] fix(migrate): guard report timestamp formatting --- src/commands/migrate/context.test.ts | 18 ++++++++++++++++++ src/commands/migrate/context.ts | 3 ++- src/config/sessions/artifacts.ts | 8 ++------ src/shared/number-coercion.test.ts | 18 ++++++++++++++++++ src/shared/number-coercion.ts | 17 +++++++++++++++++ 5 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 src/commands/migrate/context.test.ts diff --git a/src/commands/migrate/context.test.ts b/src/commands/migrate/context.test.ts new file mode 100644 index 00000000000..8e02eacf8fc --- /dev/null +++ b/src/commands/migrate/context.test.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { buildMigrationReportDir } from "./context.js"; + +describe("migration context helpers", () => { + it("builds report directories with filename-safe timestamps", () => { + const now = Date.parse("2026-02-23T12:34:56.000Z"); + expect(buildMigrationReportDir("codex", "/state", now)).toBe( + path.join("/state", "migration", "codex", "2026-02-23T12-34-56.000Z"), + ); + }); + + it("falls back instead of throwing for out-of-range report timestamps", () => { + expect(buildMigrationReportDir("codex", "/state", 9_000_000_000_000_000)).toMatch( + /[/\\]migration[/\\]codex[/\\]\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z$/, + ); + }); +}); diff --git a/src/commands/migrate/context.ts b/src/commands/migrate/context.ts index 93f47b35be5..1ea17293b99 100644 --- a/src/commands/migrate/context.ts +++ b/src/commands/migrate/context.ts @@ -4,6 +4,7 @@ import { resolveStateDir } from "../../config/paths.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { MigrationProviderContext } from "../../plugins/types.js"; import type { RuntimeEnv } from "../../runtime.js"; +import { timestampMsToIsoFileStamp } from "../../shared/number-coercion.js"; export function createMigrationLogger(runtime: RuntimeEnv, opts: { json?: boolean } = {}) { const info = opts.json ? runtime.error : runtime.log; @@ -24,7 +25,7 @@ export function buildMigrationReportDir( stateDir: string, nowMs = Date.now(), ): string { - const stamp = new Date(nowMs).toISOString().replaceAll(":", "-"); + const stamp = timestampMsToIsoFileStamp(nowMs); return path.join(stateDir, "migration", providerId, stamp); } diff --git a/src/config/sessions/artifacts.ts b/src/config/sessions/artifacts.ts index 8100dc27068..114141a37d0 100644 --- a/src/config/sessions/artifacts.ts +++ b/src/config/sessions/artifacts.ts @@ -1,4 +1,4 @@ -import { timestampMsToIsoString } from "../../shared/number-coercion.js"; +import { timestampMsToIsoFileStamp } from "../../shared/number-coercion.js"; import { escapeRegExp } from "../../shared/regexp.js"; export type SessionArchiveReason = "bak" | "reset" | "deleted"; @@ -123,11 +123,7 @@ export function parseUsageCountedSessionIdFromFileName(fileName: string): string } export function formatSessionArchiveTimestamp(nowMs = Date.now()): string { - const iso = - timestampMsToIsoString(nowMs) ?? - timestampMsToIsoString(Date.now()) ?? - "1970-01-01T00:00:00.000Z"; - return iso.replaceAll(":", "-"); + return timestampMsToIsoFileStamp(nowMs); } function restoreSessionArchiveTimestamp(raw: string): string { diff --git a/src/shared/number-coercion.test.ts b/src/shared/number-coercion.test.ts index a2b6a619baf..7543865f7ac 100644 --- a/src/shared/number-coercion.test.ts +++ b/src/shared/number-coercion.test.ts @@ -25,6 +25,8 @@ import { parseStrictNonNegativeInteger, parseStrictPositiveInteger, resolveTimerTimeoutMs, + resolveTimestampMsToIsoString, + timestampMsToIsoFileStamp, timestampMsToIsoString, } from "./number-coercion.js"; @@ -132,6 +134,22 @@ describe("number-coercion", () => { expect(timestampMsToIsoString("0")).toBeUndefined(); }); + test("timestamp fallback helpers resolve Date-invalid timestamps", () => { + expect(resolveTimestampMsToIsoString(0)).toBe("1970-01-01T00:00:00.000Z"); + expect(resolveTimestampMsToIsoString(Number.POSITIVE_INFINITY, 1_000)).toBe( + "1970-01-01T00:00:01.000Z", + ); + expect(resolveTimestampMsToIsoString(Number.POSITIVE_INFINITY, Number.NaN)).toBe( + "1970-01-01T00:00:00.000Z", + ); + expect(timestampMsToIsoFileStamp(Date.parse("2026-02-23T12:34:56.000Z"))).toBe( + "2026-02-23T12-34-56.000Z", + ); + expect(timestampMsToIsoFileStamp(9_000_000_000_000_000, 1_000)).toBe( + "1970-01-01T00-00-01.000Z", + ); + }); + test("expiry helpers resolve safe absolute timestamps", () => { expect( resolveExpiresAtMsFromDurationSeconds("3600", { diff --git a/src/shared/number-coercion.ts b/src/shared/number-coercion.ts index 0a20e4bbf47..043e9c8431e 100644 --- a/src/shared/number-coercion.ts +++ b/src/shared/number-coercion.ts @@ -96,6 +96,7 @@ export function asPositiveSafeInteger(value: unknown): number | undefined { export const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; export const MAX_TIMER_TIMEOUT_SECONDS = Math.floor(MAX_TIMER_TIMEOUT_MS / 1000); export const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000; +export const UNIX_EPOCH_ISO_STRING = "1970-01-01T00:00:00.000Z"; export function asDateTimestampMs(value: unknown): number | undefined { return asFiniteNumberInRange(value, { @@ -109,6 +110,22 @@ export function timestampMsToIsoString(value: unknown): string | undefined { return timestampMs === undefined ? undefined : new Date(timestampMs).toISOString(); } +export function resolveTimestampMsToIsoString( + value: unknown, + fallbackValue: unknown = Date.now(), +): string { + return ( + timestampMsToIsoString(value) ?? timestampMsToIsoString(fallbackValue) ?? UNIX_EPOCH_ISO_STRING + ); +} + +export function timestampMsToIsoFileStamp( + value: unknown, + fallbackValue: unknown = Date.now(), +): string { + return resolveTimestampMsToIsoString(value, fallbackValue).replaceAll(":", "-"); +} + export function clampTimerTimeoutMs(valueMs: unknown, minMs = 1): number | undefined { const value = asFiniteNumber(valueMs); if (value === undefined) {