feat: use userTimezone as default for cron schedules

Fixes #3318

Previously, cron schedules without an explicit `tz` field would use
the system timezone, which could cause confusion if the server is in
a different timezone than the user.

Now, `agents.defaults.userTimezone` from the config is used as the
default timezone for cron schedule computations when the schedule
doesn't specify its own `tz`.

Changes:
- Added `defaultTimezone` option to `computeNextRunAtMs()`
- Added `defaultTimezone` to `CronServiceDeps`
- Gateway passes `userTimezone` from config to cron service
- All job computations now respect the default timezone
This commit is contained in:
Tes Sal 2026-01-28 13:03:17 +00:00
parent 3f83afe4a6
commit 8d34bcf32e
4 changed files with 29 additions and 6 deletions

View File

@ -1,7 +1,16 @@
import { Cron } from "croner"; import { Cron } from "croner";
import type { CronSchedule } from "./types.js"; import type { CronSchedule } from "./types.js";
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined { export type ComputeNextRunOpts = {
/** Default timezone to use when schedule.tz is not specified. */
defaultTimezone?: string;
};
export function computeNextRunAtMs(
schedule: CronSchedule,
nowMs: number,
opts?: ComputeNextRunOpts,
): number | undefined {
if (schedule.kind === "at") { if (schedule.kind === "at") {
return schedule.atMs > nowMs ? schedule.atMs : undefined; return schedule.atMs > nowMs ? schedule.atMs : undefined;
} }
@ -17,8 +26,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe
const expr = schedule.expr.trim(); const expr = schedule.expr.trim();
if (!expr) return undefined; if (!expr) return undefined;
// Use schedule.tz if specified, otherwise fall back to default timezone
const timezone = schedule.tz?.trim() || opts?.defaultTimezone?.trim() || undefined;
const cron = new Cron(expr, { const cron = new Cron(expr, {
timezone: schedule.tz?.trim() || undefined, timezone,
catch: false, catch: false,
}); });
const next = cron.nextRun(new Date(nowMs)); const next = cron.nextRun(new Date(nowMs));

View File

@ -33,19 +33,24 @@ export function findJobOrThrow(state: CronServiceState, id: string) {
return job; return job;
} }
export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | undefined { export function computeJobNextRunAtMs(
job: CronJob,
nowMs: number,
opts?: { defaultTimezone?: string },
): number | undefined {
if (!job.enabled) return undefined; if (!job.enabled) return undefined;
if (job.schedule.kind === "at") { if (job.schedule.kind === "at") {
// One-shot jobs stay due until they successfully finish. // One-shot jobs stay due until they successfully finish.
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return undefined; if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return undefined;
return job.schedule.atMs; return job.schedule.atMs;
} }
return computeNextRunAtMs(job.schedule, nowMs); return computeNextRunAtMs(job.schedule, nowMs, { defaultTimezone: opts?.defaultTimezone });
} }
export function recomputeNextRuns(state: CronServiceState) { export function recomputeNextRuns(state: CronServiceState) {
if (!state.store) return; if (!state.store) return;
const now = state.deps.nowMs(); const now = state.deps.nowMs();
const defaultTimezone = state.deps.defaultTimezone;
for (const job of state.store.jobs) { for (const job of state.store.jobs) {
if (!job.state) job.state = {}; if (!job.state) job.state = {};
if (!job.enabled) { if (!job.enabled) {
@ -61,7 +66,7 @@ export function recomputeNextRuns(state: CronServiceState) {
); );
job.state.runningAtMs = undefined; job.state.runningAtMs = undefined;
} }
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now, { defaultTimezone });
} }
} }
@ -77,6 +82,7 @@ export function nextWakeAtMs(state: CronServiceState) {
export function createJob(state: CronServiceState, input: CronJobCreate): CronJob { export function createJob(state: CronServiceState, input: CronJobCreate): CronJob {
const now = state.deps.nowMs(); const now = state.deps.nowMs();
const defaultTimezone = state.deps.defaultTimezone;
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const job: CronJob = { const job: CronJob = {
id, id,
@ -97,7 +103,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo
}, },
}; };
assertSupportedJobSpec(job); assertSupportedJobSpec(job);
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); job.state.nextRunAtMs = computeJobNextRunAtMs(job, now, { defaultTimezone });
return job; return job;
} }

View File

@ -24,6 +24,8 @@ export type CronServiceDeps = {
log: Logger; log: Logger;
storePath: string; storePath: string;
cronEnabled: boolean; cronEnabled: boolean;
/** Default timezone (IANA) for cron schedules that don't specify their own. */
defaultTimezone?: string;
enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void; enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void;
requestHeartbeatNow: (opts?: { reason?: string }) => void; requestHeartbeatNow: (opts?: { reason?: string }) => void;
runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<HeartbeatRunResult>; runHeartbeatOnce?: (opts?: { reason?: string }) => Promise<HeartbeatRunResult>;

View File

@ -43,9 +43,13 @@ export function buildGatewayCronService(params: {
return { agentId, cfg: runtimeConfig }; return { agentId, cfg: runtimeConfig };
}; };
// Use userTimezone from config as default for cron schedules
const defaultTimezone = params.cfg.agents?.defaults?.userTimezone;
const cron = new CronService({ const cron = new CronService({
storePath, storePath,
cronEnabled, cronEnabled,
defaultTimezone,
enqueueSystemEvent: (text, opts) => { enqueueSystemEvent: (text, opts) => {
const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId);
const sessionKey = resolveAgentMainSessionKey({ const sessionKey = resolveAgentMainSessionKey({