mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add timezone-aware cron schedules (#1232)
* feat: add timezone-aware cron schedules * chore: normalize schedule timezone property * fix: reject blank schedule timezone in mcp * Stabilize terminal resize browser repro * Fix terminal resize repro row assertions --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
committed by
GitHub
parent
922a93af2f
commit
632c48fde3
@@ -206,7 +206,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string }` — cron expression
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
|
||||
@@ -144,19 +144,19 @@ describe("terminal resize reflow repro (Paseo terminal)", () => {
|
||||
it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
|
||||
await page.viewport(1600, 700);
|
||||
const m = mount(560, 360); // ~70 cols
|
||||
await waitFor(() => m.terminal.cols > 0);
|
||||
await waitFor(() => m.terminal.cols > 40);
|
||||
const narrowCols = m.terminal.cols;
|
||||
|
||||
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
|
||||
// long line wrapped at the server width as separate grid rows.
|
||||
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
|
||||
await waitFor(() => someRowContains(m.terminal, '"seq":1'));
|
||||
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
|
||||
|
||||
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
|
||||
for (let seq = 90; seq <= 96; seq++) {
|
||||
await writeCommitted(m.runtime, encodeTerminalOutput(pinoLine(seq)));
|
||||
}
|
||||
await waitFor(() => someRowContains(m.terminal, '"seq":96'));
|
||||
await waitFor(() => someRowContains(m.terminal, "[15:30:36.123]"));
|
||||
|
||||
// 3) User resizes the terminal wider.
|
||||
m.root.style.width = "1480px";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
@@ -34,6 +35,7 @@ export async function runCreateCommand(
|
||||
prompt,
|
||||
every: options.every,
|
||||
cron: options.cron,
|
||||
timezone: options.timezone,
|
||||
name: options.name,
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createScheduleCommand(): Command {
|
||||
.argument("<prompt>", "Prompt to run on the schedule")
|
||||
.option("--every <duration>", "Fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (default: UTC)")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option(
|
||||
@@ -82,6 +83,7 @@ export function createScheduleCommand(): Command {
|
||||
.argument("<id>", "Schedule ID")
|
||||
.option("--every <duration>", "Switch to fixed interval cadence (for example: 5m, 1h)")
|
||||
.option("--cron <expr>", "Switch to cron cadence expression")
|
||||
.option("--timezone <iana>", "IANA time zone for cron cadence (requires --cron)")
|
||||
.option("--name <name>", "Rename the schedule (empty string clears the name)")
|
||||
.option("--prompt <text>", "Replace the schedule prompt")
|
||||
.option(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OutputSchema } from "../../output/index.js";
|
||||
import { formatTarget, type ScheduleRow } from "./shared.js";
|
||||
import { formatCadence, formatTarget, type ScheduleRow } from "./shared.js";
|
||||
import type { ScheduleRecord, ScheduleRunRecord } from "./types.js";
|
||||
|
||||
export const scheduleSchema: OutputSchema<ScheduleRow> = {
|
||||
@@ -73,7 +73,7 @@ export function createScheduleInspectRows(schedule: ScheduleRecord): ScheduleIns
|
||||
key: "Cadence",
|
||||
value:
|
||||
schedule.cadence.type === "cron"
|
||||
? `cron:${schedule.cadence.expression}`
|
||||
? formatCadence(schedule.cadence)
|
||||
: `every:${schedule.cadence.everyMs}ms`,
|
||||
},
|
||||
{ key: "Target", value: formatTarget(schedule.target) },
|
||||
|
||||
@@ -88,6 +88,19 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
expect(input.runOnCreate).toBe(true);
|
||||
});
|
||||
|
||||
test("--cron with --timezone stores a timezone-aware cadence", () => {
|
||||
const input = parseScheduleCreateInput({
|
||||
...baseCron,
|
||||
timezone: " America/New_York ",
|
||||
});
|
||||
|
||||
expect(input.cadence).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
});
|
||||
|
||||
test("--every with --run-now is rejected as redundant", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, runNow: true })).toThrow(
|
||||
expect.objectContaining({
|
||||
@@ -105,6 +118,15 @@ describe("parseScheduleCreateInput first-run timing", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("--timezone without --cron is rejected", () => {
|
||||
expect(() => parseScheduleCreateInput({ ...baseOptions, timezone: "Europe/Zurich" })).toThrow(
|
||||
expect.objectContaining({
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone can only be used with --cron",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseScheduleUpdateInput", () => {
|
||||
@@ -151,12 +173,31 @@ describe("parseScheduleUpdateInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses --cron cadence with --timezone", () => {
|
||||
expect(
|
||||
parseScheduleUpdateInput({
|
||||
id: "abc",
|
||||
cron: "30 9 * * *",
|
||||
timezone: "Europe/Zurich",
|
||||
}),
|
||||
).toEqual({
|
||||
id: "abc",
|
||||
cadence: { type: "cron", expression: "30 9 * * *", timezone: "Europe/Zurich" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects passing both --every and --cron", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc", every: "5m", cron: "0 9 * * *" })).toThrow(
|
||||
expect.objectContaining({ code: "INVALID_CADENCE" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects --timezone without --cron", () => {
|
||||
expect(() => parseScheduleUpdateInput({ id: "abc", timezone: "Europe/Zurich" })).toThrow(
|
||||
expect.objectContaining({ code: "INVALID_TIME_ZONE" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("parses provider/model shorthand and explicit mode", () => {
|
||||
expect(
|
||||
parseScheduleUpdateInput({
|
||||
|
||||
@@ -49,7 +49,8 @@ export function toScheduleCommandError(code: string, action: string, error: unkn
|
||||
|
||||
export function formatCadence(cadence: ScheduleCadence): string {
|
||||
if (cadence.type === "cron") {
|
||||
return `cron:${cadence.expression}`;
|
||||
const timezoneSuffix = cadence.timezone ? ` (${cadence.timezone})` : "";
|
||||
return `cron:${cadence.expression}${timezoneSuffix}`;
|
||||
}
|
||||
return `every:${formatDurationMs(cadence.everyMs)}`;
|
||||
}
|
||||
@@ -129,6 +130,7 @@ export function parseScheduleCreateInput(options: {
|
||||
prompt: string;
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
@@ -147,7 +149,7 @@ export function parseScheduleCreateInput(options: {
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron);
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
|
||||
if (!cadence) {
|
||||
throw {
|
||||
code: "INVALID_CADENCE",
|
||||
@@ -232,6 +234,7 @@ export interface ScheduleUpdateOptionsInput {
|
||||
id: string;
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
@@ -253,7 +256,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron);
|
||||
const cadence = parseCadenceFromFlags(options.every, options.cron, options.timezone);
|
||||
const newAgentConfig = buildNewAgentConfigPatch(options);
|
||||
const maxRuns = parseUpdateMaxRuns(options);
|
||||
const expiresAt = parseUpdateExpiresAt(options);
|
||||
@@ -288,6 +291,7 @@ export function parseScheduleUpdateInput(options: ScheduleUpdateOptionsInput): U
|
||||
function parseCadenceFromFlags(
|
||||
every: string | undefined,
|
||||
cron: string | undefined,
|
||||
timezone: string | undefined,
|
||||
): ScheduleCadence | undefined {
|
||||
if (every !== undefined && cron !== undefined) {
|
||||
throw {
|
||||
@@ -295,15 +299,40 @@ function parseCadenceFromFlags(
|
||||
message: "Specify at most one of --every or --cron",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const trimmedTimeZone = parseTimeZoneFlag(timezone);
|
||||
if (trimmedTimeZone !== undefined && cron === undefined) {
|
||||
throw {
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone can only be used with --cron",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
if (every !== undefined) {
|
||||
return { type: "every", everyMs: parseDuration(every) };
|
||||
}
|
||||
if (cron !== undefined) {
|
||||
return { type: "cron", expression: cron.trim() };
|
||||
return {
|
||||
type: "cron",
|
||||
expression: cron.trim(),
|
||||
...(trimmedTimeZone ? { timezone: trimmedTimeZone } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseTimeZoneFlag(timeZone: string | undefined): string | undefined {
|
||||
if (timeZone === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = timeZone.trim();
|
||||
if (!trimmed) {
|
||||
throw {
|
||||
code: "INVALID_TIME_ZONE",
|
||||
message: "--timezone cannot be empty",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseUpdateMaxRuns(options: ScheduleUpdateOptionsInput): number | null | undefined {
|
||||
if (options.maxRuns !== undefined && options.clearMaxRuns) {
|
||||
throw {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ScheduleCadence =
|
||||
| {
|
||||
type: "cron";
|
||||
expression: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type ScheduleTarget =
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
export interface ScheduleUpdateOptions extends ScheduleCommandOptions {
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
@@ -36,6 +37,7 @@ export async function runUpdateCommand(
|
||||
id,
|
||||
every: options.every,
|
||||
cron: options.cron,
|
||||
timezone: options.timezone,
|
||||
name: options.name,
|
||||
prompt: options.prompt,
|
||||
provider: options.provider,
|
||||
|
||||
@@ -568,6 +568,7 @@ export interface CreateScheduleOptions {
|
||||
| {
|
||||
type: "cron";
|
||||
expression: string;
|
||||
timezone?: string;
|
||||
};
|
||||
target:
|
||||
| {
|
||||
@@ -623,6 +624,7 @@ export interface UpdateScheduleOptions {
|
||||
| {
|
||||
type: "cron";
|
||||
expression: string;
|
||||
timezone?: string;
|
||||
};
|
||||
newAgentConfig?: UpdateScheduleNewAgentConfig;
|
||||
maxRuns?: number | null;
|
||||
|
||||
26
packages/protocol/src/schedule/types.test.ts
Normal file
26
packages/protocol/src/schedule/types.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ScheduleCadenceSchema } from "./types.js";
|
||||
|
||||
describe("ScheduleCadenceSchema", () => {
|
||||
test("accepts existing UTC cron cadence without a time zone", () => {
|
||||
expect(ScheduleCadenceSchema.parse({ type: "cron", expression: "0 9 * * *" })).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts timezone-aware cron cadence", () => {
|
||||
expect(
|
||||
ScheduleCadenceSchema.parse({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "America/New_York",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ export const ScheduleCadenceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("cron"),
|
||||
expression: z.string().trim().min(1),
|
||||
timezone: z.string().trim().min(1).optional(),
|
||||
}),
|
||||
]);
|
||||
export type ScheduleCadence = z.infer<typeof ScheduleCadenceSchema>;
|
||||
|
||||
@@ -2328,6 +2328,38 @@ describe("create_schedule MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes timezone through cron create_schedule input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn(async (scheduleInput: CreateScheduleInput) =>
|
||||
createStoredSchedule(scheduleInput),
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone: " America/New_York ",
|
||||
provider: "codex",
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: "0 9 * * 1-5",
|
||||
timezone: "America/New_York",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still rejects both real every and cron inputs", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
@@ -2352,6 +2384,54 @@ describe("create_schedule MCP tool", () => {
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects create_schedule timezone without cron", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
every: "10m",
|
||||
timezone: "America/New_York",
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow("timezone can only be used with cron");
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", " "])("rejects create_schedule blank timezone %#", async (timezone) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const create = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { create } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
prompt: "say hello",
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone,
|
||||
provider: "codex",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "missing both cadence fields",
|
||||
@@ -2458,6 +2538,35 @@ describe("update_schedule MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("passes timezone through cron update_schedule input", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const stored = makeStoredSchedule();
|
||||
const update = vi.fn(async (_input: UpdateScheduleInput) => stored);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
id: "schedule-1",
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone: "Europe/Zurich",
|
||||
});
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
id: "schedule-1",
|
||||
cadence: {
|
||||
type: "cron",
|
||||
expression: "0 9 * * 1-5",
|
||||
timezone: "Europe/Zurich",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a blank cron field when updating every cadence", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const stored = makeStoredSchedule();
|
||||
@@ -2542,6 +2651,52 @@ describe("update_schedule MCP tool", () => {
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects update_schedule timezone without cron", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const update = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
id: "schedule-1",
|
||||
every: "10m",
|
||||
timezone: "Europe/Zurich",
|
||||
}),
|
||||
).rejects.toThrow("timezone can only be used with cron");
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", " "])("rejects update_schedule blank timezone %#", async (timezone) => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const update = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
scheduleService: { update } as unknown as ScheduleService,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "update_schedule");
|
||||
|
||||
await expect(
|
||||
invokeToolWithParsedInput(tool, {
|
||||
id: "schedule-1",
|
||||
cron: "0 9 * * 1-5",
|
||||
timezone,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes new-agent config and expiry updates", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const stored = makeStoredSchedule();
|
||||
|
||||
@@ -333,6 +333,7 @@ interface ScheduleUpdateToolInput {
|
||||
id: string;
|
||||
every?: string;
|
||||
cron?: string;
|
||||
timezone?: string;
|
||||
name?: string | null;
|
||||
prompt?: string;
|
||||
maxRuns?: number | null;
|
||||
@@ -357,18 +358,30 @@ function normalizeScheduleCadenceArg(value: string | undefined): string | undefi
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function normalizeScheduleTimeZoneArg(value: string | undefined): string | undefined {
|
||||
return normalizeScheduleCadenceArg(value);
|
||||
}
|
||||
|
||||
function resolveScheduleUpdateCadence(input: ScheduleUpdateToolInput): ScheduleCadence | undefined {
|
||||
const every = normalizeScheduleCadenceArg(input.every);
|
||||
const cron = normalizeScheduleCadenceArg(input.cron);
|
||||
const timeZone = normalizeScheduleTimeZoneArg(input.timezone);
|
||||
|
||||
if (every !== undefined && cron !== undefined) {
|
||||
throw new Error("Specify at most one of every or cron");
|
||||
}
|
||||
if (timeZone !== undefined && cron === undefined) {
|
||||
throw new Error("timezone can only be used with cron");
|
||||
}
|
||||
if (every !== undefined) {
|
||||
return { type: "every", everyMs: parseDurationString(every) };
|
||||
}
|
||||
if (cron !== undefined) {
|
||||
return { type: "cron", expression: cron };
|
||||
return {
|
||||
type: "cron",
|
||||
expression: cron,
|
||||
...(timeZone !== undefined ? { timezone: timeZone } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1583,6 +1596,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
prompt: z.string().trim().min(1, "prompt is required"),
|
||||
every: z.string().optional(),
|
||||
cron: z.string().optional(),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
|
||||
),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
@@ -1594,18 +1615,22 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, every, cron, timezone, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
|
||||
const normalizedEvery = normalizeScheduleCadenceArg(every);
|
||||
const normalizedCron = normalizeScheduleCadenceArg(cron);
|
||||
const normalizedTimeZone = normalizeScheduleTimeZoneArg(timezone);
|
||||
const cadenceCount =
|
||||
Number(normalizedEvery !== undefined) + Number(normalizedCron !== undefined);
|
||||
if (cadenceCount !== 1) {
|
||||
throw new Error("Specify exactly one of every or cron");
|
||||
}
|
||||
if (normalizedTimeZone !== undefined && normalizedCron === undefined) {
|
||||
throw new Error("timezone can only be used with cron");
|
||||
}
|
||||
|
||||
const scheduleTarget =
|
||||
target === "self"
|
||||
@@ -1643,7 +1668,11 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cadence:
|
||||
normalizedEvery !== undefined
|
||||
? { type: "every" as const, everyMs: parseDurationString(normalizedEvery) }
|
||||
: { type: "cron" as const, expression: normalizedCron! },
|
||||
: {
|
||||
type: "cron" as const,
|
||||
expression: normalizedCron!,
|
||||
...(normalizedTimeZone !== undefined ? { timezone: normalizedTimeZone } : {}),
|
||||
},
|
||||
target: scheduleTarget,
|
||||
...(name?.trim() ? { name: name.trim() } : {}),
|
||||
...(maxRuns === undefined ? {} : { maxRuns }),
|
||||
@@ -1792,6 +1821,14 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
id: z.string(),
|
||||
every: z.string().optional().describe("New interval duration string (e.g. 5m, 1h)."),
|
||||
cron: z.string().optional().describe("New cron expression."),
|
||||
timezone: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"IANA time zone for cron cadence; requires cron. For example: America/New_York.",
|
||||
),
|
||||
name: z.string().nullable().optional().describe("New name (null to clear)."),
|
||||
prompt: z.string().trim().min(1).optional().describe("New prompt text."),
|
||||
maxRuns: z
|
||||
|
||||
@@ -20,9 +20,47 @@ describe("schedule cron cadence", () => {
|
||||
expect(next.toISOString()).toBe("2026-01-05T09:15:00.000Z");
|
||||
});
|
||||
|
||||
test("computes timezone cron matches at the requested wall-clock time", () => {
|
||||
const winter = computeNextRunAt(
|
||||
{ type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" },
|
||||
new Date("2026-01-05T13:59:30.000Z"),
|
||||
);
|
||||
const summer = computeNextRunAt(
|
||||
{ type: "cron", expression: "0 9 * * 1-5", timezone: "America/New_York" },
|
||||
new Date("2026-07-06T12:59:30.000Z"),
|
||||
);
|
||||
|
||||
expect(winter.toISOString()).toBe("2026-01-05T14:00:00.000Z");
|
||||
expect(summer.toISOString()).toBe("2026-07-06T13:00:00.000Z");
|
||||
});
|
||||
|
||||
test("keeps repeated fall-back wall-clock matches distinct", () => {
|
||||
const first = computeNextRunAt(
|
||||
{ type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" },
|
||||
new Date("2026-11-01T05:29:30.000Z"),
|
||||
);
|
||||
const second = computeNextRunAt(
|
||||
{ type: "cron", expression: "30 1 1 11 *", timezone: "America/New_York" },
|
||||
first,
|
||||
);
|
||||
|
||||
expect(first.toISOString()).toBe("2026-11-01T05:30:00.000Z");
|
||||
expect(second.toISOString()).toBe("2026-11-01T06:30:00.000Z");
|
||||
});
|
||||
|
||||
test("rejects invalid cron expressions", () => {
|
||||
expect(() => validateScheduleCadence({ type: "cron", expression: "not-a-valid-cron" })).toThrow(
|
||||
"Cron expressions must have 5 fields",
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid cron time zones", () => {
|
||||
expect(() =>
|
||||
validateScheduleCadence({
|
||||
type: "cron",
|
||||
expression: "0 9 * * *",
|
||||
timezone: "Not/AZone",
|
||||
}),
|
||||
).toThrow("Invalid cron time zone: Not/AZone");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,14 @@ interface ParsedCronExpression {
|
||||
dayOfWeek: CronFieldMatcher;
|
||||
}
|
||||
|
||||
interface CronDateParts {
|
||||
minute: number;
|
||||
hour: number;
|
||||
dayOfMonth: number;
|
||||
month: number;
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
function parseCronExpression(expression: string): ParsedCronExpression {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
@@ -110,9 +118,65 @@ function startOfNextMinute(date: Date): Date {
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidTimeZone(timeZone: string): void {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date(0));
|
||||
} catch {
|
||||
throw new Error(`Invalid cron time zone: ${timeZone}`);
|
||||
}
|
||||
}
|
||||
|
||||
function createCronDatePartsReader(timeZone: string | undefined): (date: Date) => CronDateParts {
|
||||
if (timeZone === undefined) {
|
||||
return (date: Date) => ({
|
||||
minute: date.getUTCMinutes(),
|
||||
hour: date.getUTCHours(),
|
||||
dayOfMonth: date.getUTCDate(),
|
||||
month: date.getUTCMonth() + 1,
|
||||
dayOfWeek: date.getUTCDay(),
|
||||
});
|
||||
}
|
||||
|
||||
assertValidTimeZone(timeZone);
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (date: Date) => {
|
||||
const values: Record<string, string> = {};
|
||||
for (const part of formatter.formatToParts(date)) {
|
||||
if (part.type !== "literal") {
|
||||
values[part.type] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
const year = Number.parseInt(values.year, 10);
|
||||
const month = Number.parseInt(values.month, 10);
|
||||
const dayOfMonth = Number.parseInt(values.day, 10);
|
||||
|
||||
return {
|
||||
minute: Number.parseInt(values.minute, 10),
|
||||
hour: Number.parseInt(values.hour, 10),
|
||||
dayOfMonth,
|
||||
month,
|
||||
dayOfWeek: new Date(Date.UTC(year, month - 1, dayOfMonth)).getUTCDay(),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function validateScheduleCadence(cadence: ScheduleCadence): void {
|
||||
if (cadence.type === "cron") {
|
||||
parseCronExpression(cadence.expression);
|
||||
if (cadence.timezone !== undefined) {
|
||||
assertValidTimeZone(cadence.timezone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,15 +186,12 @@ export function computeNextRunAt(cadence: ScheduleCadence, after: Date): Date {
|
||||
}
|
||||
|
||||
const cron = parseCronExpression(cadence.expression);
|
||||
const readDateParts = createCronDatePartsReader(cadence.timezone);
|
||||
const limit = 366 * 24 * 60;
|
||||
let cursor = startOfNextMinute(after);
|
||||
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const minute = cursor.getUTCMinutes();
|
||||
const hour = cursor.getUTCHours();
|
||||
const dayOfMonth = cursor.getUTCDate();
|
||||
const month = cursor.getUTCMonth() + 1;
|
||||
const dayOfWeek = cursor.getUTCDay();
|
||||
const { minute, hour, dayOfMonth, month, dayOfWeek } = readDateParts(cursor);
|
||||
|
||||
if (
|
||||
cron.minute.matches(minute) &&
|
||||
|
||||
@@ -59,6 +59,7 @@ Daily GitHub triage on GLM through OpenCode:
|
||||
```bash
|
||||
paseo schedule create \
|
||||
--cron "0 14 * * 1-5" \
|
||||
--timezone UTC \
|
||||
--run-now \
|
||||
--name github-triage \
|
||||
--provider opencode/openrouter/glm-5.1 \
|
||||
@@ -66,6 +67,18 @@ paseo schedule create \
|
||||
"Triage GitHub issues, PRs, and failing checks. Summarize what needs attention."
|
||||
```
|
||||
|
||||
Morning triage at 9 AM in New York, including daylight saving time changes:
|
||||
|
||||
```bash
|
||||
paseo schedule create \
|
||||
--cron "0 9 * * 1-5" \
|
||||
--timezone America/New_York \
|
||||
--name morning-triage \
|
||||
--provider codex/gpt-5.5 \
|
||||
--cwd ~/dev/my-app \
|
||||
"Review overnight CI failures and summarize anything urgent."
|
||||
```
|
||||
|
||||
Heartbeat the current agent:
|
||||
|
||||
```bash
|
||||
@@ -89,7 +102,9 @@ paseo schedule update <id> --every 10m --max-runs 6
|
||||
paseo schedule delete <id>
|
||||
```
|
||||
|
||||
Use `--every <duration>` for intervals and `--cron "<expr>"` for 5-field UTC cron. Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately.
|
||||
Use `--every <duration>` for intervals and `--cron "<expr>"` for 5-field cron. Cron schedules default to UTC. Pass `--timezone <IANA>` to interpret cron fields in a local wall-clock time zone, for example `--timezone America/New_York`. The persisted `nextRunAt` is still a UTC instant, but it is computed from that local time zone so recurring jobs stay at the same local time across daylight saving time changes.
|
||||
|
||||
Interval schedules run once immediately by default; pass `--no-run-now` to wait for the first interval. Cron schedules wait for the next matching time; pass `--run-now` to fire once immediately.
|
||||
|
||||
When targeting a remote daemon with `--host`, pass `--cwd`; your local working directory may not exist on the remote machine.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user