Centralize date/time formatting utilities (#11831)

This commit is contained in:
max
2026-02-08 04:53:31 -08:00
committed by GitHub
parent 74fbbda283
commit a1123dd9be
77 changed files with 1508 additions and 1075 deletions
+22 -81
View File
@@ -2,6 +2,12 @@ import type { OpenClawConfig } from "../config/config.js";
import { resolveUserTimezone } from "../agents/date-time.js";
import { normalizeChatType } from "../channels/chat-type.js";
import { resolveSenderLabel, type SenderLabelParams } from "../channels/sender-label.js";
import {
resolveTimezone,
formatUtcTimestamp,
formatZonedTimestamp,
} from "../infra/format-time/format-datetime.ts";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
export type AgentEnvelopeParams = {
channel: string;
@@ -66,15 +72,6 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
};
}
function resolveExplicitTimezone(value: string): string | undefined {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
}
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim();
if (!trimmed) {
@@ -90,46 +87,10 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
if (lowered === "user") {
return { mode: "iana", timeZone: resolveUserTimezone(options.userTimezone) };
}
const explicit = resolveExplicitTimezone(trimmed);
const explicit = resolveTimezone(trimmed);
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
}
function formatUtcTimestamp(date: Date): string {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`;
}
export function formatZonedTimestamp(date: Date, timeZone?: string): string | undefined {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min) {
return undefined;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`;
}
function formatTimestamp(
ts: number | Date | undefined,
options?: EnvelopeFormatOptions,
@@ -152,47 +113,27 @@ function formatTimestamp(
if (zone.mode === "local") {
return formatZonedTimestamp(date);
}
return formatZonedTimestamp(date, zone.timeZone);
}
function formatElapsedTime(currentMs: number, previousMs: number): string | undefined {
const elapsedMs = currentMs - previousMs;
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) {
return undefined;
}
const seconds = Math.floor(elapsedMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h`;
}
const days = Math.floor(hours / 24);
return `${days}d`;
return formatZonedTimestamp(date, { timeZone: zone.timeZone });
}
export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
const channel = params.channel?.trim() || "Channel";
const parts: string[] = [channel];
const resolved = normalizeEnvelopeOptions(params.envelope);
const elapsed =
resolved.includeElapsed && params.timestamp && params.previousTimestamp
? formatElapsedTime(
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp,
params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp,
)
: undefined;
let elapsed: string | undefined;
if (resolved.includeElapsed && params.timestamp && params.previousTimestamp) {
const currentMs =
params.timestamp instanceof Date ? params.timestamp.getTime() : params.timestamp;
const previousMs =
params.previousTimestamp instanceof Date
? params.previousTimestamp.getTime()
: params.previousTimestamp;
const elapsedMs = currentMs - previousMs;
elapsed =
Number.isFinite(elapsedMs) && elapsedMs >= 0
? formatTimeAgo(elapsedMs, { suffix: false })
: undefined;
}
if (params.from?.trim()) {
const from = params.from.trim();
parts.push(elapsed ? `${from} +${elapsed}` : from);
+7 -11
View File
@@ -14,17 +14,13 @@ import {
import { loadSessionStore, resolveStorePath, updateSessionStore } from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js";
import { logVerbose } from "../../globals.js";
import { formatDurationCompact } from "../../infra/format-time/format-duration.ts";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { stopSubagentsForRequester } from "./abort.js";
import { clearSessionQueues } from "./queue.js";
import {
formatAgeShort,
formatDurationShort,
formatRunLabel,
formatRunStatus,
sortSubagentRuns,
} from "./subagents-utils.js";
import { formatRunLabel, formatRunStatus, sortSubagentRuns } from "./subagents-utils.js";
type SubagentTargetResolution = {
entry?: SubagentRunRecord;
@@ -45,7 +41,7 @@ function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return `${formatTimestamp(valueMs)} (${formatAgeShort(Date.now() - valueMs)})`;
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
@@ -214,8 +210,8 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const label = formatRunLabel(entry);
const runtime =
entry.endedAt && entry.startedAt
? formatDurationShort(entry.endedAt - entry.startedAt)
: formatAgeShort(Date.now() - (entry.startedAt ?? entry.createdAt));
? (formatDurationCompact(entry.endedAt - entry.startedAt) ?? "n/a")
: formatTimeAgo(Date.now() - (entry.startedAt ?? entry.createdAt), { fallback: "n/a" });
const runId = entry.runId.slice(0, 8);
lines.push(
`${index + 1}) ${status} · ${label} · ${runtime} · run ${runId} · ${entry.childSessionKey}`,
@@ -296,7 +292,7 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? formatDurationShort((run.endedAt ?? Date.now()) - run.startedAt)
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
+12 -52
View File
@@ -5,6 +5,11 @@ import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { buildChannelSummary } from "../../infra/channel-summary.js";
import {
resolveTimezone,
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../infra/format-time/format-datetime.ts";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { drainSystemEventEntries } from "../../infra/system-events.js";
@@ -39,15 +44,6 @@ export async function prependSystemEvents(params: {
return trimmed;
};
const resolveExplicitTimezone = (value: string): string | undefined => {
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) {
@@ -66,49 +62,10 @@ export async function prependSystemEvents(params: {
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
const explicit = resolveTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0");
const hh = String(date.getUTCHours()).padStart(2, "0");
const min = String(date.getUTCMinutes()).padStart(2, "0");
const sec = String(date.getUTCSeconds()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
};
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.toReversed()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) {
return undefined;
}
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
@@ -116,12 +73,15 @@ export async function prependSystemEvents(params: {
}
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") {
return formatUtcTimestamp(date);
return formatUtcTimestamp(date, { displaySeconds: true });
}
if (zone.mode === "local") {
return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, { displaySeconds: true }) ?? "unknown-time";
}
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
return (
formatZonedTimestamp(date, { timeZone: zone.timeZone, displaySeconds: true }) ??
"unknown-time"
);
};
const systemLines: string[] = [];
+4 -4
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
import {
formatDurationShort,
formatRunLabel,
formatRunStatus,
resolveSubagentLabel,
@@ -54,8 +54,8 @@ describe("subagents utils", () => {
);
});
it("formats duration short for seconds and minutes", () => {
expect(formatDurationShort(45_000)).toBe("45s");
expect(formatDurationShort(65_000)).toBe("1m5s");
it("formats duration compact for seconds and minutes", () => {
expect(formatDurationCompact(45_000)).toBe("45s");
expect(formatDurationCompact(65_000)).toBe("1m5s");
});
});
-36
View File
@@ -1,42 +1,6 @@
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import { truncateUtf16Safe } from "../../utils.js";
export function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const totalSeconds = Math.round(valueMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}h${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m${seconds}s`;
}
return `${seconds}s`;
}
export function formatAgeShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
const minutes = Math.round(valueMs / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
}
export function resolveSubagentLabel(entry: SubagentRunRecord, fallback = "subagent") {
const raw = entry.label?.trim() || entry.task?.trim() || "";
return raw || fallback;
+2 -20
View File
@@ -16,6 +16,7 @@ import {
type SessionEntry,
type SessionScope,
} from "../config/sessions.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { resolveCommitHash } from "../infra/git-commit.js";
import { listPluginCommands } from "../plugins/commands.js";
import {
@@ -134,25 +135,6 @@ export const formatContextUsageShort = (
contextTokens: number | null | undefined,
) => `Context ${formatTokens(total, contextTokens ?? null)}`;
const formatAge = (ms?: number | null) => {
if (!ms || ms < 0) {
return "unknown";
}
const minutes = Math.round(ms / 60_000);
if (minutes < 1) {
return "just now";
}
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.round(minutes / 60);
if (hours < 48) {
return `${hours}h ago`;
}
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const formatQueueDetails = (queue?: QueueStatus) => {
if (!queue) {
return "";
@@ -386,7 +368,7 @@ export function buildStatusMessage(args: StatusArgs): string {
const updatedAt = entry?.updatedAt;
const sessionLine = [
`Session: ${args.sessionKey ?? "unknown"}`,
typeof updatedAt === "number" ? `updated ${formatAge(now - updatedAt)}` : "no activity",
typeof updatedAt === "number" ? `updated ${formatTimeAgo(now - updatedAt)}` : "no activity",
]
.filter(Boolean)
.join(" • ");