mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:21:35 +07:00
refactor: extract cron schedule and test runner helpers
This commit is contained in:
@@ -42,6 +42,13 @@ const writeTempJsonArtifact = (name, value) => {
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8");
|
||||
return filePath;
|
||||
};
|
||||
const sanitizeArtifactName = (value) => {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^a-z0-9._-]+/giu, "-")
|
||||
.replace(/^-+|-+$/gu, "");
|
||||
return normalized || "artifact";
|
||||
};
|
||||
const cleanupTempArtifacts = () => {
|
||||
if (tempArtifactDir === null) {
|
||||
return;
|
||||
@@ -1055,6 +1062,13 @@ const heapSnapshotBaseDir = heapSnapshotEnabled
|
||||
const ensureNodeOptionFlag = (nodeOptions, flagPrefix, nextValue) =>
|
||||
nodeOptions.includes(flagPrefix) ? nodeOptions : `${nodeOptions} ${nextValue}`.trim();
|
||||
const isNodeLikeProcess = (command) => /(?:^|\/)node(?:$|\.exe$)/iu.test(command);
|
||||
const getShardLabel = (args) => {
|
||||
const shardIndex = args.findIndex((arg) => arg === "--shard");
|
||||
if (shardIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
return typeof args[shardIndex + 1] === "string" ? args[shardIndex + 1] : "";
|
||||
};
|
||||
|
||||
const runOnce = (entry, extraArgs = []) =>
|
||||
new Promise((resolve) => {
|
||||
@@ -1072,6 +1086,19 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
...extraArgs,
|
||||
]
|
||||
: [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs];
|
||||
const shardLabel = getShardLabel(extraArgs);
|
||||
const artifactStem = [
|
||||
sanitizeArtifactName(entry.name),
|
||||
shardLabel ? `shard-${sanitizeArtifactName(shardLabel)}` : "",
|
||||
String(startedAt),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-");
|
||||
const laneLogPath = path.join(ensureTempArtifactDir(), `${artifactStem}.log`);
|
||||
const laneLogStream = fs.createWriteStream(laneLogPath, { flags: "w" });
|
||||
laneLogStream.write(`[test-parallel] entry=${entry.name}\n`);
|
||||
laneLogStream.write(`[test-parallel] cwd=${process.cwd()}\n`);
|
||||
laneLogStream.write(`[test-parallel] command=${[pnpm, ...args].join(" ")}\n\n`);
|
||||
console.log(
|
||||
`[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String(
|
||||
countExplicitEntryFilters(entryArgs) ?? "all",
|
||||
@@ -1264,6 +1291,7 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
}, heapSnapshotIntervalMs);
|
||||
}
|
||||
} catch (err) {
|
||||
laneLogStream.end();
|
||||
console.error(`[test-parallel] spawn failed: ${String(err)}`);
|
||||
resolve(1);
|
||||
return;
|
||||
@@ -1273,6 +1301,7 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
const text = chunk.toString();
|
||||
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
|
||||
output = appendCapturedOutput(output, text);
|
||||
laneLogStream.write(text);
|
||||
logMemoryTraceForText(text);
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
@@ -1280,11 +1309,13 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
const text = chunk.toString();
|
||||
fatalSeen ||= hasFatalTestRunOutput(`${output}${text}`);
|
||||
output = appendCapturedOutput(output, text);
|
||||
laneLogStream.write(text);
|
||||
logMemoryTraceForText(text);
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
childError = err;
|
||||
laneLogStream.write(`\n[test-parallel] child error: ${String(err)}\n`);
|
||||
console.error(`[test-parallel] child error: ${String(err)}`);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
@@ -1296,15 +1327,36 @@ const runOnce = (entry, extraArgs = []) =>
|
||||
}
|
||||
children.delete(child);
|
||||
const resolvedCode = resolveTestRunExitCode({ code, signal, output, fatalSeen, childError });
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
logMemoryTraceSummary();
|
||||
if (resolvedCode !== 0) {
|
||||
const failureTail = formatCapturedOutputTail(output);
|
||||
const failureArtifactPath = writeTempJsonArtifact(`${artifactStem}-failure`, {
|
||||
entry: entry.name,
|
||||
command: [pnpm, ...args],
|
||||
elapsedMs,
|
||||
error: childError ? String(childError) : null,
|
||||
exitCode: resolvedCode,
|
||||
fatalSeen,
|
||||
logPath: laneLogPath,
|
||||
outputTail: failureTail,
|
||||
signal: signal ?? null,
|
||||
});
|
||||
if (failureTail) {
|
||||
console.error(`[test-parallel] failure tail ${entry.name}\n${failureTail}`);
|
||||
}
|
||||
console.error(
|
||||
`[test-parallel] failure artifacts ${entry.name} log=${laneLogPath} meta=${failureArtifactPath}`,
|
||||
);
|
||||
}
|
||||
laneLogStream.write(
|
||||
`\n[test-parallel] done ${entry.name} code=${String(resolvedCode)} signal=${
|
||||
signal ?? "none"
|
||||
} elapsed=${formatElapsedMs(elapsedMs)}\n`,
|
||||
);
|
||||
laneLogStream.end();
|
||||
console.log(
|
||||
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(Date.now() - startedAt)}`,
|
||||
`[test-parallel] done ${entry.name} code=${String(resolvedCode)} elapsed=${formatElapsedMs(elapsedMs)}`,
|
||||
);
|
||||
resolve(resolvedCode);
|
||||
});
|
||||
|
||||
@@ -614,6 +614,23 @@ describe("cron cli", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects --tz with --every on cron add", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"invalid",
|
||||
"--every",
|
||||
"10m",
|
||||
"--tz",
|
||||
"UTC",
|
||||
"--session",
|
||||
"main",
|
||||
"--system-event",
|
||||
"tick",
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies --tz to --at for offset-less datetimes on cron add", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
|
||||
@@ -5,12 +5,10 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import { resolveCronCreateSchedule } from "./schedule-options.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
handleCronCliError,
|
||||
parseAt,
|
||||
parseCronStaggerMs,
|
||||
parseDurationMs,
|
||||
printCronJson,
|
||||
printCronList,
|
||||
warnIfCronSchedulerDisabled,
|
||||
@@ -104,47 +102,14 @@ export function registerCronAddCommand(cron: Command) {
|
||||
.option("--json", "Output JSON", false)
|
||||
.action(async (opts: GatewayRpcOpts & Record<string, unknown>, cmd?: Command) => {
|
||||
try {
|
||||
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
|
||||
const useExact = Boolean(opts.exact);
|
||||
if (staggerRaw && useExact) {
|
||||
throw new Error("Choose either --stagger or --exact, not both");
|
||||
}
|
||||
|
||||
const schedule = (() => {
|
||||
const at = typeof opts.at === "string" ? opts.at : "";
|
||||
const every = typeof opts.every === "string" ? opts.every : "";
|
||||
const cronExpr = typeof opts.cron === "string" ? opts.cron : "";
|
||||
const chosen = [Boolean(at), Boolean(every), Boolean(cronExpr)].filter(Boolean).length;
|
||||
if (chosen !== 1) {
|
||||
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
|
||||
}
|
||||
if ((useExact || staggerRaw) && !cronExpr) {
|
||||
throw new Error("--stagger/--exact are only valid with --cron");
|
||||
}
|
||||
if (at) {
|
||||
const tzRaw =
|
||||
typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined;
|
||||
const atIso = parseAt(at, tzRaw);
|
||||
if (!atIso) {
|
||||
throw new Error("Invalid --at; use ISO time or duration like 20m");
|
||||
}
|
||||
return { kind: "at" as const, at: atIso };
|
||||
}
|
||||
if (every) {
|
||||
const everyMs = parseDurationMs(every);
|
||||
if (!everyMs) {
|
||||
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
|
||||
}
|
||||
return { kind: "every" as const, everyMs };
|
||||
}
|
||||
const staggerMs = parseCronStaggerMs({ staggerRaw, useExact });
|
||||
return {
|
||||
kind: "cron" as const,
|
||||
expr: cronExpr,
|
||||
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
|
||||
staggerMs,
|
||||
};
|
||||
})();
|
||||
const schedule = resolveCronCreateSchedule({
|
||||
at: opts.at,
|
||||
cron: opts.cron,
|
||||
every: opts.every,
|
||||
exact: opts.exact,
|
||||
stagger: opts.stagger,
|
||||
tz: opts.tz,
|
||||
});
|
||||
|
||||
const wakeModeRaw = typeof opts.wake === "string" ? opts.wake : "now";
|
||||
const wakeMode = wakeModeRaw.trim() || "now";
|
||||
|
||||
@@ -5,12 +5,10 @@ import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
parseAt,
|
||||
parseCronStaggerMs,
|
||||
parseDurationMs,
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
applyExistingCronSchedulePatch,
|
||||
resolveCronEditScheduleRequest,
|
||||
} from "./schedule-options.js";
|
||||
import { getCronChannelOptions, parseDurationMs, warnIfCronSchedulerDisabled } from "./shared.js";
|
||||
|
||||
const assignIf = (
|
||||
target: Record<string, unknown>,
|
||||
@@ -97,13 +95,6 @@ export function registerCronEditCommand(cron: Command) {
|
||||
if (opts.announce && typeof opts.deliver === "boolean") {
|
||||
throw new Error("Choose --announce or --no-deliver (not multiple).");
|
||||
}
|
||||
const staggerRaw = typeof opts.stagger === "string" ? opts.stagger.trim() : "";
|
||||
const useExact = Boolean(opts.exact);
|
||||
if (staggerRaw && useExact) {
|
||||
throw new Error("Choose either --stagger or --exact, not both");
|
||||
}
|
||||
const requestedStaggerMs = parseCronStaggerMs({ staggerRaw, useExact });
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof opts.name === "string") {
|
||||
patch.name = opts.name;
|
||||
@@ -154,38 +145,17 @@ export function registerCronEditCommand(cron: Command) {
|
||||
patch.sessionKey = null;
|
||||
}
|
||||
|
||||
const scheduleChosen = [opts.at, opts.every, opts.cron].filter(Boolean).length;
|
||||
if (scheduleChosen > 1) {
|
||||
throw new Error("Choose at most one schedule change");
|
||||
}
|
||||
if (typeof opts.tz === "string" && opts.every) {
|
||||
throw new Error("--tz is only valid with --cron or offset-less --at");
|
||||
}
|
||||
if (requestedStaggerMs !== undefined && (opts.at || opts.every)) {
|
||||
throw new Error("--stagger/--exact are only valid for cron schedules");
|
||||
}
|
||||
if (opts.at) {
|
||||
const tzRaw =
|
||||
typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined;
|
||||
const atIso = parseAt(String(opts.at), tzRaw);
|
||||
if (!atIso) {
|
||||
throw new Error("Invalid --at");
|
||||
}
|
||||
patch.schedule = { kind: "at", at: atIso };
|
||||
} else if (opts.every) {
|
||||
const everyMs = parseDurationMs(String(opts.every));
|
||||
if (!everyMs) {
|
||||
throw new Error("Invalid --every");
|
||||
}
|
||||
patch.schedule = { kind: "every", everyMs };
|
||||
} else if (opts.cron) {
|
||||
patch.schedule = {
|
||||
kind: "cron",
|
||||
expr: String(opts.cron),
|
||||
tz: typeof opts.tz === "string" && opts.tz.trim() ? opts.tz.trim() : undefined,
|
||||
staggerMs: requestedStaggerMs,
|
||||
};
|
||||
} else if (requestedStaggerMs !== undefined || typeof opts.tz === "string") {
|
||||
const scheduleRequest = resolveCronEditScheduleRequest({
|
||||
at: opts.at,
|
||||
cron: opts.cron,
|
||||
every: opts.every,
|
||||
exact: opts.exact,
|
||||
stagger: opts.stagger,
|
||||
tz: opts.tz,
|
||||
});
|
||||
if (scheduleRequest.kind === "direct") {
|
||||
patch.schedule = scheduleRequest.schedule;
|
||||
} else if (scheduleRequest.kind === "patch-existing-cron") {
|
||||
const listed = (await callGatewayFromCli("cron.list", opts, {
|
||||
includeDisabled: true,
|
||||
})) as { jobs?: CronJob[] } | null;
|
||||
@@ -193,18 +163,7 @@ export function registerCronEditCommand(cron: Command) {
|
||||
if (!existing) {
|
||||
throw new Error(`unknown cron job id: ${id}`);
|
||||
}
|
||||
if (existing.schedule.kind !== "cron") {
|
||||
throw new Error("Current job is not a cron schedule; use --cron to convert first");
|
||||
}
|
||||
const tz =
|
||||
typeof opts.tz === "string" ? opts.tz.trim() || undefined : existing.schedule.tz;
|
||||
patch.schedule = {
|
||||
kind: "cron",
|
||||
expr: existing.schedule.expr,
|
||||
tz,
|
||||
staggerMs:
|
||||
requestedStaggerMs !== undefined ? requestedStaggerMs : existing.schedule.staggerMs,
|
||||
};
|
||||
patch.schedule = applyExistingCronSchedulePatch(existing.schedule, scheduleRequest);
|
||||
}
|
||||
|
||||
const hasSystemEventPatch = typeof opts.systemEvent === "string";
|
||||
|
||||
135
src/cli/cron-cli/schedule-options.ts
Normal file
135
src/cli/cron-cli/schedule-options.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { CronSchedule } from "../../cron/types.js";
|
||||
import { parseAt, parseCronStaggerMs, parseDurationMs } from "./shared.js";
|
||||
|
||||
type ScheduleOptionInput = {
|
||||
at?: unknown;
|
||||
cron?: unknown;
|
||||
every?: unknown;
|
||||
exact?: unknown;
|
||||
stagger?: unknown;
|
||||
tz?: unknown;
|
||||
};
|
||||
|
||||
type NormalizedScheduleOptions = {
|
||||
at: string;
|
||||
cronExpr: string;
|
||||
every: string;
|
||||
requestedStaggerMs: number | undefined;
|
||||
tz: string | undefined;
|
||||
};
|
||||
|
||||
export type CronEditScheduleRequest =
|
||||
| { kind: "direct"; schedule: CronSchedule }
|
||||
| { kind: "patch-existing-cron"; staggerMs: number | undefined; tz: string | undefined }
|
||||
| { kind: "none" };
|
||||
|
||||
export function resolveCronCreateSchedule(options: ScheduleOptionInput): CronSchedule {
|
||||
const normalized = normalizeScheduleOptions(options);
|
||||
const chosen = countChosenSchedules(normalized);
|
||||
if (chosen !== 1) {
|
||||
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
|
||||
}
|
||||
const schedule = resolveDirectSchedule(normalized);
|
||||
if (!schedule) {
|
||||
throw new Error("Choose exactly one schedule: --at, --every, or --cron");
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
export function resolveCronEditScheduleRequest(
|
||||
options: ScheduleOptionInput,
|
||||
): CronEditScheduleRequest {
|
||||
const normalized = normalizeScheduleOptions(options);
|
||||
const chosen = countChosenSchedules(normalized);
|
||||
if (chosen > 1) {
|
||||
throw new Error("Choose at most one schedule change");
|
||||
}
|
||||
const schedule = resolveDirectSchedule(normalized);
|
||||
if (schedule) {
|
||||
return { kind: "direct", schedule };
|
||||
}
|
||||
if (normalized.requestedStaggerMs !== undefined || normalized.tz !== undefined) {
|
||||
return {
|
||||
kind: "patch-existing-cron",
|
||||
tz: normalized.tz,
|
||||
staggerMs: normalized.requestedStaggerMs,
|
||||
};
|
||||
}
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
export function applyExistingCronSchedulePatch(
|
||||
existingSchedule: CronSchedule,
|
||||
request: Extract<CronEditScheduleRequest, { kind: "patch-existing-cron" }>,
|
||||
): CronSchedule {
|
||||
if (existingSchedule.kind !== "cron") {
|
||||
throw new Error("Current job is not a cron schedule; use --cron to convert first");
|
||||
}
|
||||
return {
|
||||
kind: "cron",
|
||||
expr: existingSchedule.expr,
|
||||
tz: request.tz ?? existingSchedule.tz,
|
||||
staggerMs: request.staggerMs !== undefined ? request.staggerMs : existingSchedule.staggerMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeScheduleOptions(options: ScheduleOptionInput): NormalizedScheduleOptions {
|
||||
const staggerRaw = readTrimmedString(options.stagger);
|
||||
const useExact = Boolean(options.exact);
|
||||
if (staggerRaw && useExact) {
|
||||
throw new Error("Choose either --stagger or --exact, not both");
|
||||
}
|
||||
return {
|
||||
at: readTrimmedString(options.at),
|
||||
every: readTrimmedString(options.every),
|
||||
cronExpr: readTrimmedString(options.cron),
|
||||
tz: readOptionalString(options.tz),
|
||||
requestedStaggerMs: parseCronStaggerMs({ staggerRaw, useExact }),
|
||||
};
|
||||
}
|
||||
|
||||
function countChosenSchedules(options: NormalizedScheduleOptions): number {
|
||||
return [Boolean(options.at), Boolean(options.every), Boolean(options.cronExpr)].filter(Boolean)
|
||||
.length;
|
||||
}
|
||||
|
||||
function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule | undefined {
|
||||
if (options.tz && options.every) {
|
||||
throw new Error("--tz is only valid with --cron or offset-less --at");
|
||||
}
|
||||
if (options.requestedStaggerMs !== undefined && (options.at || options.every)) {
|
||||
throw new Error("--stagger/--exact are only valid for cron schedules");
|
||||
}
|
||||
if (options.at) {
|
||||
const atIso = parseAt(options.at, options.tz);
|
||||
if (!atIso) {
|
||||
throw new Error("Invalid --at; use ISO time or duration like 20m");
|
||||
}
|
||||
return { kind: "at", at: atIso };
|
||||
}
|
||||
if (options.every) {
|
||||
const everyMs = parseDurationMs(options.every);
|
||||
if (!everyMs) {
|
||||
throw new Error("Invalid --every; use e.g. 10m, 1h, 1d");
|
||||
}
|
||||
return { kind: "every", everyMs };
|
||||
}
|
||||
if (options.cronExpr) {
|
||||
return {
|
||||
kind: "cron",
|
||||
expr: options.cronExpr,
|
||||
tz: options.tz,
|
||||
staggerMs: options.requestedStaggerMs,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readOptionalString(value: unknown): string | undefined {
|
||||
const trimmed = readTrimmedString(value);
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import { resolveCronStaggerMs } from "../../cron/stagger.js";
|
||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
||||
import {
|
||||
isOffsetlessIsoDateTime,
|
||||
parseOffsetlessIsoDateTimeInTimeZone,
|
||||
} from "../../infra/format-time/parse-offsetless-zoned-datetime.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
@@ -89,8 +93,6 @@ export function parseCronStaggerMs(params: {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
|
||||
|
||||
/**
|
||||
* Parse a one-shot `--at` value into an ISO string (UTC).
|
||||
*
|
||||
@@ -106,8 +108,8 @@ export function parseAt(input: string, tz?: string): string | null {
|
||||
|
||||
// If a timezone is provided and the input looks like an offset-less ISO datetime,
|
||||
// resolve it in the given IANA timezone so users get the time they expect.
|
||||
if (tz && OFFSETLESS_ISO_DATETIME_RE.test(raw)) {
|
||||
const resolved = parseOffsetlessAtInTimezone(raw, tz);
|
||||
if (tz && isOffsetlessIsoDateTime(raw)) {
|
||||
const resolved = parseOffsetlessIsoDateTimeInTimeZone(raw, tz);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
@@ -124,62 +126,6 @@ export function parseAt(input: string, tz?: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOffsetlessAtInTimezone(raw: string, tz: string): string | null {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
|
||||
|
||||
const naiveMs = new Date(`${raw}Z`).getTime();
|
||||
if (Number.isNaN(naiveMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-check the offset at the first candidate instant so DST boundaries
|
||||
// land on the intended wall-clock time instead of drifting by one hour.
|
||||
const firstOffsetMs = getTimezoneOffsetMs(naiveMs, tz);
|
||||
const candidateMs = naiveMs - firstOffsetMs;
|
||||
const finalOffsetMs = getTimezoneOffsetMs(candidateMs, tz);
|
||||
return new Date(naiveMs - finalOffsetMs).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the UTC offset in milliseconds for a given IANA timezone at a given UTC instant.
|
||||
* Positive means ahead of UTC (e.g. +3600000 for CET).
|
||||
*/
|
||||
function getTimezoneOffsetMs(utcMs: number, tz: string): number {
|
||||
const d = new Date(utcMs);
|
||||
// Format parts in the target timezone
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(d);
|
||||
|
||||
const get = (type: string) => {
|
||||
const part = parts.find((p) => p.type === type);
|
||||
return Number.parseInt(part?.value ?? "0", 10);
|
||||
};
|
||||
|
||||
// Reconstruct the local time as if it were UTC
|
||||
const localAsUtc = Date.UTC(
|
||||
get("year"),
|
||||
get("month") - 1,
|
||||
get("day"),
|
||||
get("hour"),
|
||||
get("minute"),
|
||||
get("second"),
|
||||
);
|
||||
|
||||
return localAsUtc - utcMs;
|
||||
}
|
||||
|
||||
const CRON_ID_PAD = 36;
|
||||
const CRON_NAME_PAD = 24;
|
||||
const CRON_SCHEDULE_PAD = 32;
|
||||
|
||||
@@ -53,11 +53,7 @@ import {
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "./validation.js";
|
||||
import {
|
||||
compareOpenClawVersions,
|
||||
isSameOpenClawStableFamily,
|
||||
parseOpenClawVersion,
|
||||
} from "./version.js";
|
||||
import { shouldWarnOnTouchedVersion } from "./version.js";
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { CircularIncludeError, ConfigIncludeError } from "./includes.js";
|
||||
@@ -626,29 +622,7 @@ function warnIfConfigFromFuture(cfg: OpenClawConfig, logger: Pick<typeof console
|
||||
if (!touched) {
|
||||
return;
|
||||
}
|
||||
const currentVersion = parseOpenClawVersion(VERSION);
|
||||
const touchedVersion = parseOpenClawVersion(touched);
|
||||
if (!currentVersion || !touchedVersion) {
|
||||
return;
|
||||
}
|
||||
if (isSameOpenClawStableFamily(VERSION, touched)) {
|
||||
return;
|
||||
}
|
||||
const sameBaseRelease =
|
||||
currentVersion.major === touchedVersion.major &&
|
||||
currentVersion.minor === touchedVersion.minor &&
|
||||
currentVersion.patch === touchedVersion.patch;
|
||||
if (sameBaseRelease && touchedVersion.prerelease?.length) {
|
||||
logger.warn(
|
||||
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cmp = compareOpenClawVersions(VERSION, touched);
|
||||
if (cmp === null) {
|
||||
return;
|
||||
}
|
||||
if (cmp < 0) {
|
||||
if (shouldWarnOnTouchedVersion(VERSION, touched)) {
|
||||
logger.warn(
|
||||
`Config was last written by a newer OpenClaw (${touched}); current version is ${VERSION}.`,
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
compareOpenClawVersions,
|
||||
isSameOpenClawStableFamily,
|
||||
parseOpenClawVersion,
|
||||
shouldWarnOnTouchedVersion,
|
||||
} from "./version.js";
|
||||
|
||||
describe("parseOpenClawVersion", () => {
|
||||
@@ -65,3 +66,18 @@ describe("isSameOpenClawStableFamily", () => {
|
||||
expect(isSameOpenClawStableFamily("2026.3.23-beta.1", "2026.3.23")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnOnTouchedVersion", () => {
|
||||
it("skips same-base stable families", () => {
|
||||
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips same-base prerelease configs when current is newer", () => {
|
||||
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.23-beta.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("warns when the touched config is newer", () => {
|
||||
expect(shouldWarnOnTouchedVersion("2026.3.23-beta.1", "2026.3.23")).toBe(true);
|
||||
expect(shouldWarnOnTouchedVersion("2026.3.23", "2026.3.24")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,17 @@ export function compareOpenClawVersions(
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function shouldWarnOnTouchedVersion(
|
||||
current: string | null | undefined,
|
||||
touched: string | null | undefined,
|
||||
): boolean {
|
||||
if (isSameOpenClawStableFamily(current, touched)) {
|
||||
return false;
|
||||
}
|
||||
const cmp = compareOpenClawVersions(current, touched);
|
||||
return cmp !== null && cmp < 0;
|
||||
}
|
||||
|
||||
function normalizeLegacyDotBetaVersion(version: string): string {
|
||||
const dotBetaMatch = /^([vV]?[0-9]+\.[0-9]+\.[0-9]+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(version);
|
||||
if (!dotBetaMatch) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isOffsetlessIsoDateTime,
|
||||
parseOffsetlessIsoDateTimeInTimeZone,
|
||||
} from "./parse-offsetless-zoned-datetime.js";
|
||||
|
||||
describe("parseOffsetlessIsoDateTimeInTimeZone", () => {
|
||||
it("detects offset-less ISO datetimes", () => {
|
||||
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00")).toBe(true);
|
||||
expect(isOffsetlessIsoDateTime("2026-03-23T23:00:00+02:00")).toBe(false);
|
||||
expect(isOffsetlessIsoDateTime("+20m")).toBe(false);
|
||||
});
|
||||
|
||||
it("converts offset-less datetimes in the requested timezone", () => {
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Europe/Oslo")).toBe(
|
||||
"2026-03-23T22:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps DST boundary conversions on the intended wall-clock time", () => {
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-29T01:30:00", "Europe/Oslo")).toBe(
|
||||
"2026-03-29T00:30:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for invalid input", () => {
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00+02:00", "Europe/Oslo")).toBe(
|
||||
null,
|
||||
);
|
||||
expect(parseOffsetlessIsoDateTimeInTimeZone("2026-03-23T23:00:00", "Invalid/Timezone")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
58
src/infra/format-time/parse-offsetless-zoned-datetime.ts
Normal file
58
src/infra/format-time/parse-offsetless-zoned-datetime.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/;
|
||||
|
||||
export function isOffsetlessIsoDateTime(raw: string): boolean {
|
||||
return OFFSETLESS_ISO_DATETIME_RE.test(raw);
|
||||
}
|
||||
|
||||
export function parseOffsetlessIsoDateTimeInTimeZone(raw: string, timeZone: string): string | null {
|
||||
if (!isOffsetlessIsoDateTime(raw)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date());
|
||||
|
||||
const naiveMs = new Date(`${raw}Z`).getTime();
|
||||
if (Number.isNaN(naiveMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Re-check the offset at the first candidate instant so DST boundaries
|
||||
// land on the intended wall-clock time instead of drifting by one hour.
|
||||
const firstOffsetMs = getTimeZoneOffsetMs(naiveMs, timeZone);
|
||||
const candidateMs = naiveMs - firstOffsetMs;
|
||||
const finalOffsetMs = getTimeZoneOffsetMs(candidateMs, timeZone);
|
||||
return new Date(naiveMs - finalOffsetMs).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeZoneOffsetMs(utcMs: number, timeZone: string): number {
|
||||
const utcDate = new Date(utcMs);
|
||||
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",
|
||||
hour12: false,
|
||||
}).formatToParts(utcDate);
|
||||
|
||||
const getNumericPart = (type: string) => {
|
||||
const part = parts.find((candidate) => candidate.type === type);
|
||||
return Number.parseInt(part?.value ?? "0", 10);
|
||||
};
|
||||
|
||||
const localAsUtc = Date.UTC(
|
||||
getNumericPart("year"),
|
||||
getNumericPart("month") - 1,
|
||||
getNumericPart("day"),
|
||||
getNumericPart("hour"),
|
||||
getNumericPart("minute"),
|
||||
getNumericPart("second"),
|
||||
);
|
||||
|
||||
return localAsUtc - utcMs;
|
||||
}
|
||||
Reference in New Issue
Block a user