Skip to content

Commit 3574003

Browse files
committed
fix(cron): support time-only --at values
1 parent f2b8668 commit 3574003

8 files changed

Lines changed: 96 additions & 13 deletions

File tree

docs/automation/cron-jobs.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,13 @@ Task reconciliation for cron is runtime-owned first, durable-history-backed seco
6363

6464
## Schedule types
6565

66-
| Kind | CLI flag | Description |
67-
| ------- | --------- | ------------------------------------------------------- |
68-
| `at` | `--at` | One-shot timestamp (ISO 8601 or relative like `20m`) |
69-
| `every` | `--every` | Fixed interval |
70-
| `cron` | `--cron` | 5-field or 6-field cron expression with optional `--tz` |
66+
| Kind | CLI flag | Description |
67+
| ------- | --------- | ---------------------------------------------------------------------------- |
68+
| `at` | `--at` | One-shot time: ISO 8601, `HH:MM`/`HH:MM:SS`, or relative duration like `20m` |
69+
| `every` | `--every` | Fixed interval |
70+
| `cron` | `--cron` | 5-field or 6-field cron expression with optional `--tz` |
7171

72-
Timestamps without a timezone are treated as UTC. Add `--tz America/New_York` for local wall-clock scheduling.
72+
Timestamps and time-only values without a timezone are treated as UTC. Add `--tz America/New_York` for local wall-clock scheduling.
7373

7474
Recurring top-of-hour expressions are automatically staggered by up to 5 minutes to reduce load spikes. Use `--exact` to force precise timing or `--stagger 30s` for an explicit window.
7575

docs/cli/cron.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ reported as pre-model cron failures.
131131

132132
### One-shot jobs
133133

134-
`--at <datetime>` schedules a one-shot run. Offset-less datetimes are treated as UTC unless you also pass `--tz <iana>`, which interprets the wall-clock time in the given timezone.
134+
`--at <when>` schedules a one-shot run. It accepts ISO timestamps, offset-less datetimes, time-only values like `09:00` or `09:00:30`, and relative durations like `20m` or `+20m`. Offset-less and time-only values are treated as UTC unless you also pass `--tz <iana>`, which interprets the wall-clock time in the given timezone.
135135

136136
<Note>
137137
One-shot jobs delete after success by default. Use `--keep-after-run` to preserve them.

src/cli/cron-cli.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,9 @@ describe("cron cli", () => {
309309

310310
expect(addCommand?.helpInformation()).toContain("Gateway host local timezone");
311311
expect(editCommand?.helpInformation()).toContain("Gateway host local timezone");
312-
expect(editCommand?.helpInformation()).toMatch(/offset-less uses\s+--tz/);
312+
expect(addCommand?.helpInformation()).toContain("HH:MM[:SS]");
313+
expect(editCommand?.helpInformation()).toContain("HH:MM[:SS]");
314+
expect(editCommand?.helpInformation()).toContain("--tz for offset-less");
313315
});
314316

315317
it.each([

src/cli/cron-cli/register.cron-add.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function registerCronAddCommand(cron: Command) {
9898
.option("--wake <mode>", "Wake mode (now|next-heartbeat)", "now")
9999
.option(
100100
"--at <when>",
101-
"Run once at time (ISO with offset, or +duration). Use --tz for offset-less datetimes",
101+
"Run once at time (ISO, HH:MM[:SS], or +duration). Use --tz for offset-less values",
102102
)
103103
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
104104
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")

src/cli/cron-cli/register.cron-edit.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ export function registerCronEditCommand(cron: Command) {
8888
.option("--session-key <key>", "Set session key for job routing")
8989
.option("--clear-session-key", "Unset session key", false)
9090
.option("--wake <mode>", "Wake mode (now|next-heartbeat)")
91-
.option("--at <when>", "Set one-shot time (ISO, offset-less uses --tz) or duration like 20m")
91+
.option(
92+
"--at <when>",
93+
"Set one-shot time (ISO, HH:MM[:SS], or +duration). Use --tz for offset-less values",
94+
)
9295
.option("--every <duration>", "Set interval duration like 10m")
9396
.option("--cron <expr>", "Set cron expression")
9497
.option(

src/cli/cron-cli/schedule-options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ function resolveDirectSchedule(options: NormalizedScheduleOptions): CronSchedule
148148
if (options.at) {
149149
const atIso = parseAt(options.at, options.tz);
150150
if (!atIso) {
151-
throw new Error("Invalid --at. Use an ISO timestamp or a duration like 20m.");
151+
throw new Error("Invalid --at. Use an ISO timestamp, HH:MM time, or a duration like 20m.");
152152
}
153153
return { kind: "at", at: atIso };
154154
}

src/cli/cron-cli/shared.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,35 @@ describe("parseAt", () => {
249249
expect(parseAt("30m")).toBe("2026-05-25T00:30:00.000Z");
250250
});
251251

252+
it("accepts bare HH:MM and HH:MM:SS time-only values as UTC today", () => {
253+
vi.useFakeTimers();
254+
vi.setSystemTime(new Date("2026-05-25T23:30:00.000Z"));
255+
256+
expect(parseAt("09:00")).toBe("2026-05-25T09:00:00.000Z");
257+
expect(parseAt("09:00:30")).toBe("2026-05-25T09:00:30.000Z");
258+
});
259+
260+
it("uses --tz to resolve time-only values against today's date in that timezone", () => {
261+
vi.useFakeTimers();
262+
vi.setSystemTime(new Date("2026-05-24T16:30:00.000Z"));
263+
264+
expect(parseAt("09:00", "Asia/Shanghai")).toBe("2026-05-25T01:00:00.000Z");
265+
});
266+
267+
it("returns null for invalid time-only timezones", () => {
268+
expect(parseAt("09:00", "Mars/Base")).toBeNull();
269+
});
270+
271+
it("returns null for invalid offset-less ISO timezones", () => {
272+
expect(parseAt("2026-05-25T09:00:00", "Mars/Base")).toBeNull();
273+
});
274+
275+
it("rejects out-of-range time-only values", () => {
276+
expect(parseAt("24:00")).toBeNull();
277+
expect(parseAt("12:60")).toBeNull();
278+
expect(parseAt("12:30:60")).toBeNull();
279+
});
280+
252281
it("rejects out-of-range epoch milliseconds", () => {
253282
expect(parseAt(String(Number.MAX_SAFE_INTEGER))).toBeNull();
254283
});

src/cli/cron-cli/shared.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,11 +280,49 @@ export function parseCronFallbacks(input: unknown): string[] | undefined {
280280
.filter((fallback): fallback is string => Boolean(fallback));
281281
}
282282

283+
const TIME_ONLY_RE = /^(\d{2}):(\d{2})(?::(\d{2}))?$/u;
284+
285+
function parseTimeOnlyIsoTime(raw: string): string | null | undefined {
286+
const match = TIME_ONLY_RE.exec(raw);
287+
if (!match) {
288+
return undefined;
289+
}
290+
const hour = Number.parseInt(match[1] ?? "", 10);
291+
const minute = Number.parseInt(match[2] ?? "", 10);
292+
const second = Number.parseInt(match[3] ?? "0", 10);
293+
if (hour > 23 || minute > 59 || second > 59) {
294+
return null;
295+
}
296+
return `${match[1]}:${match[2]}:${match[3] ?? "00"}`;
297+
}
298+
299+
function getCurrentUtcDateString(): string {
300+
return new Date(Date.now()).toISOString().slice(0, 10);
301+
}
302+
303+
function getCurrentDateStringInTimeZone(timeZone: string): string | null {
304+
try {
305+
const parts = new Intl.DateTimeFormat("en-US", {
306+
timeZone,
307+
year: "numeric",
308+
month: "2-digit",
309+
day: "2-digit",
310+
}).formatToParts(new Date(Date.now()));
311+
const getPart = (type: string) => parts.find((part) => part.type === type)?.value;
312+
const year = getPart("year");
313+
const month = getPart("month");
314+
const day = getPart("day");
315+
return year && month && day ? `${year}-${month}-${day}` : null;
316+
} catch {
317+
return null;
318+
}
319+
}
320+
283321
/**
284322
* Parse a one-shot `--at` value into an ISO string (UTC).
285323
*
286-
* When `tz` is provided and the input is an offset-less datetime
287-
* (e.g. `2026-03-23T23:00:00`), the datetime is interpreted in
324+
* When `tz` is provided and the input is an offset-less datetime or time-only
325+
* value (e.g. `2026-03-23T23:00:00` or `09:00`), the value is interpreted in
288326
* that IANA timezone instead of UTC.
289327
*/
290328
export function parseAt(input: string, tz?: string): string | null {
@@ -293,6 +331,17 @@ export function parseAt(input: string, tz?: string): string | null {
293331
return null;
294332
}
295333

334+
const timeOnlyIsoTime = parseTimeOnlyIsoTime(raw);
335+
if (timeOnlyIsoTime !== undefined) {
336+
if (timeOnlyIsoTime === null) {
337+
return null;
338+
}
339+
const date = tz ? getCurrentDateStringInTimeZone(tz) : getCurrentUtcDateString();
340+
return date
341+
? parseOffsetlessIsoDateTimeInTimeZone(`${date}T${timeOnlyIsoTime}`, tz ?? "UTC")
342+
: null;
343+
}
344+
296345
// If a timezone is provided and the input looks like an offset-less ISO datetime,
297346
// resolve it in the given IANA timezone so users get the time they expect.
298347
if (tz && isOffsetlessIsoDateTime(raw)) {

0 commit comments

Comments
 (0)