From 296249eea1e576ead510efb2c3955bf5de48b9a1 Mon Sep 17 00:00:00 2001 From: rolaca11 Date: Mon, 26 Oct 2026 13:19:48 +0100 Subject: [PATCH 1/2] feat(scheduled-tasks): choose a monthly run date --- .../SettingsScheduledTasksRouteScreen.tsx | 110 +++++++++------- .../settings/scheduledTaskDraft.test.ts | 21 ++++ .../features/settings/scheduledTaskDraft.ts | 18 ++- .../src/scheduledTasks/Schedule.test.ts | 30 ++++- apps/server/src/scheduledTasks/Schedule.ts | 24 +++- .../ScheduledTaskService.schedule.test.ts | 56 +++++++++ .../settings/ScheduledTasksSettings.tsx | 118 ++++++++++++------ .../scheduledTasksSettings.logic.test.ts | 18 +++ .../settings/scheduledTasksSettings.logic.ts | 16 ++- docs/user/project-settings.md | 4 +- packages/contracts/src/orchestratorMcp.ts | 2 +- packages/contracts/src/scheduledTask.test.ts | 13 ++ packages/contracts/src/scheduledTask.ts | 10 ++ 13 files changed, 349 insertions(+), 91 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index bc768b32ff875..b2ea6cf789bfb 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -109,6 +109,8 @@ const DAYS = [ function describeSchedule(task: ScheduledTask): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); + if (task.schedule.type === "monthly") + return `Day ${task.schedule.dayOfMonth} of each month at ${formatTime(task.schedule.timeOfDay)}`; const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; return `${days} at ${formatTime(task.schedule.timeOfDay)}`; } @@ -936,7 +938,8 @@ function TaskForm({ - {draft.schedule.mode === "fixed_time" ? ( + {draft.schedule.mode === "fixed_time" || draft.schedule.mode === "monthly" ? ( <> ) : null} - ({ - id: String(day.index), - title: day.label, - attributes: { keepsMenuPresented: true }, - state: draft.schedule.weekdays.includes(day.index) - ? ("on" as const) - : undefined, - })), - ]} - onSelect={(id) => { - const weekdays = - id === "every_day" - ? DAYS.map((day) => day.index) - : id === "weekdays" - ? [1, 2, 3, 4, 5] - : (() => { - const day = DAYS.find((item) => String(item.index) === id); - if (!day) return draft.schedule.weekdays; - return draft.schedule.weekdays.includes(day.index) - ? draft.schedule.weekdays.filter((index) => index !== day.index) - : [...draft.schedule.weekdays, day.index]; - })(); - setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); - }} - /> + {draft.schedule.mode === "monthly" ? ( + ({ + id: String(index + 1), + title: String(index + 1), + state: draft.schedule.dayOfMonth === String(index + 1) ? "on" : undefined, + }))} + onSelect={(dayOfMonth) => + setDraft({ ...draft, schedule: { ...draft.schedule, dayOfMonth } }) + } + /> + ) : ( + ({ + id: String(day.index), + title: day.label, + attributes: { keepsMenuPresented: true }, + state: draft.schedule.weekdays.includes(day.index) + ? ("on" as const) + : undefined, + })), + ]} + onSelect={(id) => { + const weekdays = + id === "every_day" + ? DAYS.map((day) => day.index) + : id === "weekdays" + ? [1, 2, 3, 4, 5] + : (() => { + const day = DAYS.find((item) => String(item.index) === id); + if (!day) return draft.schedule.weekdays; + return draft.schedule.weekdays.includes(day.index) + ? draft.schedule.weekdays.filter((index) => index !== day.index) + : [...draft.schedule.weekdays, day.index]; + })(); + setDraft({ ...draft, schedule: { ...draft.schedule, weekdays } }); + }} + /> + )} + {draft.schedule.mode === "monthly" ? ( + + Months without this day are skipped. + + ) : null} ) : draft.schedule.mode === "webhook" ? ( <> @@ -1127,7 +1151,7 @@ function TaskForm({ /> - {draft.schedule.mode === "fixed_time" ? ( + {draft.schedule.mode === "fixed_time" || draft.schedule.mode === "monthly" ? ( Time uses the environment's time zone, which may differ from your phone's. diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 8cf8bb5b2b72d..33fd011a41a62 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -11,9 +11,30 @@ import { DEFAULT_SCHEDULE, scheduleDraftForTask, scheduleFromDraft, + createDraft, + hasScheduledTaskDraftChanges, } from "./scheduledTaskDraft"; describe("scheduleDraftForTask", () => { + it("round-trips monthly schedules and tracks edits to their day", () => { + const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; + const draft = scheduleDraftForTask({ schedule }); + expect(draft.mode).toBe("monthly"); + expect(scheduleFromDraft(draft)).toEqual(schedule); + for (const dayOfMonth of ["", "0", "32", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, dayOfMonth })).toBeNull(); + } + expect(scheduleFromDraft({ ...draft, timeOfDay: "24:00" })).toBeNull(); + const initial = { ...createDraft(null, null), schedule: draft }; + expect(hasScheduledTaskDraftChanges(initial, initial)).toBe(false); + expect( + hasScheduledTaskDraftChanges(initial, { + ...initial, + schedule: { ...draft, dayOfMonth: "15" }, + }), + ).toBe(true); + }); + it("opens legacy sub-minute schedules at the writable minimum", () => { const draft = scheduleDraftForTask({ schedule: { type: "interval", everyMs: 30_000 } }); expect(draft.intervalMinutes).toBe("1"); diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index 3ef1a2f3faf6e..b6d208896bc72 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -51,8 +51,9 @@ export function scheduledTaskDefaultModel( } export type ScheduleDraft = { - readonly mode: "fixed_time" | "interval" | "webhook"; + readonly mode: "fixed_time" | "monthly" | "interval" | "webhook"; readonly timeOfDay: string; + readonly dayOfMonth: string; readonly weekdays: ReadonlyArray; readonly intervalMinutes: string; readonly signature: ScheduledTaskWebhookSignature | null; @@ -68,6 +69,7 @@ export type ScheduleDraft = { export const DEFAULT_SCHEDULE: ScheduleDraft = { mode: "fixed_time", timeOfDay: "09:00", + dayOfMonth: "1", weekdays: [1, 2, 3, 4, 5], intervalMinutes: "15", signature: null, @@ -81,6 +83,13 @@ export const DEFAULT_SCHEDULE: ScheduleDraft = { export function scheduleDraftForTask(task: Pick): ScheduleDraft { switch (task.schedule.type) { + case "monthly": + return { + ...DEFAULT_SCHEDULE, + mode: "monthly", + timeOfDay: task.schedule.timeOfDay, + dayOfMonth: String(task.schedule.dayOfMonth), + }; case "fixed_time": return { ...DEFAULT_SCHEDULE, @@ -167,6 +176,12 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche const everyMs = Math.round(minutes * 60_000); return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } + if (draft.mode === "monthly") { + const dayOfMonth = Number(draft.dayOfMonth); + if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null; + if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay)) return null; + return { type: "monthly", timeOfDay: draft.timeOfDay, dayOfMonth }; + } const weekdays = [...new Set(draft.weekdays)].sort((a, b) => a - b); if ( !/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay) || @@ -211,6 +226,7 @@ function draftSignature(draft: ScheduledTaskDraft): string { .map((option) => [option.id, option.value]), draft.schedule.mode, draft.schedule.timeOfDay, + draft.schedule.dayOfMonth, [...draft.schedule.weekdays].sort((a, b) => a - b), draft.schedule.intervalMinutes, draft.schedule.maxDeliveryAgeMinutes, diff --git a/apps/server/src/scheduledTasks/Schedule.test.ts b/apps/server/src/scheduledTasks/Schedule.test.ts index f5973969747f7..2bc9525967a33 100644 --- a/apps/server/src/scheduledTasks/Schedule.test.ts +++ b/apps/server/src/scheduledTasks/Schedule.test.ts @@ -1,9 +1,37 @@ import { describe, expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; -import { isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; +import { isMissedFixedTimeRun, isSameSchedule, nextScheduledRunAt } from "./Schedule.ts"; describe("scheduled task schedule calculation", () => { + it.each([ + ["2026-01-14T18:00:00Z", 15, "2026-01-15T14:00:00.000Z"], + ["2026-01-15T14:00:00Z", 15, "2026-02-15T14:00:00.000Z"], + ["2026-01-31T15:00:00Z", 31, "2026-03-31T13:00:00.000Z"], + ["2026-04-01T00:00:00Z", 31, "2026-05-31T13:00:00.000Z"], + ["2027-02-01T00:00:00Z", 29, "2027-03-29T13:00:00.000Z"], + ["2028-02-01T00:00:00Z", 29, "2028-02-29T14:00:00.000Z"], + ["2026-12-31T15:00:00Z", 1, "2027-01-01T14:00:00.000Z"], + ])("finds the monthly run after %s for day %s", (from, dayOfMonth, expected) => { + const next = nextScheduledRunAt( + { type: "monthly", dayOfMonth, timeOfDay: "09:00" }, + DateTime.makeZonedUnsafe(from, { timeZone: "America/New_York" }), + ); + expect(next && DateTime.formatIso(next)).toBe(expected); + }); + + it("compares monthly days and applies the fixed-time missed-run grace", () => { + const schedule = { type: "monthly", dayOfMonth: 15, timeOfDay: "09:00" } as const; + expect(isSameSchedule(schedule, { ...schedule, timeOfDay: "9:00" })).toBe(true); + expect(isSameSchedule(schedule, { ...schedule, dayOfMonth: 16 })).toBe(false); + expect(isSameSchedule(schedule, { ...schedule, timeOfDay: "10:00" })).toBe(false); + expect(isSameSchedule(schedule, { type: "fixed_time", timeOfDay: "09:00" })).toBe(false); + expect(isSameSchedule({ type: "fixed_time", timeOfDay: "09:00" }, schedule)).toBe(false); + const due = DateTime.makeUnsafe("2026-01-15T09:00:00Z"); + expect(isMissedFixedTimeRun(schedule, due, DateTime.add(due, { minutes: 10 }))).toBe(false); + expect(isMissedFixedTimeRun(schedule, due, DateTime.add(due, { minutes: 11 }))).toBe(true); + }); + it("skips to the next matching fixed-time weekday", () => { const next = nextScheduledRunAt( { type: "fixed_time", timeOfDay: "09:00", weekdays: [1, 2, 3, 4, 5] }, diff --git a/apps/server/src/scheduledTasks/Schedule.ts b/apps/server/src/scheduledTasks/Schedule.ts index bac32bb913146..003ec8c4c7df8 100644 --- a/apps/server/src/scheduledTasks/Schedule.ts +++ b/apps/server/src/scheduledTasks/Schedule.ts @@ -21,6 +21,22 @@ export function nextScheduledRunAt( const time = parseTimeOfDay(schedule.timeOfDay); if (time === null) return null; + if (schedule.type === "monthly") { + const monthStart = DateTime.setParts(from, { day: 1 }); + for (let offset = 0; offset <= 2; offset += 1) { + const candidate = DateTime.setParts(DateTime.add(monthStart, { months: offset }), { + day: schedule.dayOfMonth, + hour: time.hour, + minute: time.minute, + second: 0, + millisecond: 0, + }); + if (DateTime.toParts(candidate).day !== schedule.dayOfMonth) continue; + if (DateTime.toEpochMillis(candidate) <= DateTime.toEpochMillis(from)) continue; + return candidate; + } + return null; + } const weekdays = schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null; for (let offset = 0; offset <= 7; offset += 1) { @@ -48,7 +64,7 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul return b.type === "interval" && a.everyMs === b.everyMs; } if (a.type === "webhook") return b.type === "webhook"; - if (b.type !== "fixed_time") return false; + if (b.type !== a.type) return false; // Compare parsed times so padded-hour edits do not reschedule pending runs. const aTime = parseTimeOfDay(a.timeOfDay); const bTime = parseTimeOfDay(b.timeOfDay); @@ -57,7 +73,9 @@ export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedul bTime !== null && aTime.hour === bTime.hour && aTime.minute === bTime.minute && - weekdayKey(a.weekdays) === weekdayKey(b.weekdays) + (a.type === "monthly" + ? b.type === "monthly" && a.dayOfMonth === b.dayOfMonth + : b.type === "fixed_time" && weekdayKey(a.weekdays) === weekdayKey(b.weekdays)) ); } @@ -68,6 +86,6 @@ export function isMissedFixedTimeRun( dueAt: DateTime.DateTime, now: DateTime.DateTime, ): boolean { - if (schedule.type !== "fixed_time") return false; + if (schedule.type !== "fixed_time" && schedule.type !== "monthly") return false; return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS; } diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts index 09e1d7674044f..9822d0ae5abc9 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.schedule.test.ts @@ -17,6 +17,62 @@ import * as ScheduledTaskService from "./ScheduledTaskService.ts"; const decodeUpsertInput = Schema.decodeUnknownEffect(ScheduledTaskUpsertInput); +it.effect("persists monthly schedules and recalculates when their day changes", () => + Effect.gen(function* () { + const localDate = (month: number, day: number, hour: number) => + DateTime.makeZonedUnsafe( + { year: 2026, month, day, hour, minute: 0, second: 0, millisecond: 0 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ); + yield* TestClock.setTime(DateTime.toEpochMillis(localDate(1, 31, 10))); + const layerDependencies = Layer.mergeAll( + NodeCrypto.layer, + Scheduler.layer, + Layer.mock(ThreadLaunchService.ThreadLaunchService)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), + Layer.mock(ThreadManagementService.ThreadManagementService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + ); + yield* Effect.gen(function* () { + const service = yield* ScheduledTaskService.ScheduledTaskService; + const input = yield* decodeUpsertInput({ + title: "Monthly review", + prompt: "Review the open pull requests.", + enabled: true, + schedule: { type: "monthly", dayOfMonth: 31, timeOfDay: "09:00" }, + projectId: "project-monthly-schedule", + workspaceStrategy: { type: "root" }, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + }); + const created = (yield* service.upsert(input)).task; + const expected = DateTime.formatIso(localDate(3, 31, 9)); + expect(created.schedule).toEqual(input.schedule); + expect(created.nextRunAt).toBe(expected); + expect((yield* service.list()).tasks[0]?.schedule).toEqual(input.schedule); + expect( + (yield* service.setEnabled({ id: created.id, enabled: false })).task.nextRunAt, + ).toBeNull(); + expect((yield* service.setEnabled({ id: created.id, enabled: true })).task.nextRunAt).toBe( + expected, + ); + + const edited = yield* service.upsert({ ...input, id: created.id, title: "Edited" }); + expect(edited.task.nextRunAt).toBe(expected); + const rescheduled = yield* service.upsert({ + ...input, + id: created.id, + schedule: { type: "monthly", dayOfMonth: 15, timeOfDay: "09:00" }, + }); + expect(rescheduled.task.nextRunAt).toBe(DateTime.formatIso(localDate(2, 15, 9))); + }).pipe(Effect.provide(ScheduledTaskService.layer.pipe(Layer.provide(layerDependencies)))); + }).pipe(Effect.provide(SqlitePersistence.layerMemory)), +); + it.effect("rejects a stale form save after deletion while preserving explicit-id creates", () => Effect.gen(function* () { const layerDependencies = Layer.mergeAll( diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 3b842367ab4c1..9f2340cbe2106 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -138,6 +138,7 @@ const EMPTY_DRAFT: DraftState = { scheduleMode: "fixed", intervalMinutes: "15", timeOfDay: "09:00", + dayOfMonth: "1", weekdays: new Set([1, 2, 3, 4, 5]), projectId: "", threadId: "", @@ -288,6 +289,8 @@ function splitModelKey(value: string): ModelSelection | null { export function scheduleLabel(schedule: ScheduledTaskSchedule): string { if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "monthly") + return `Day ${schedule.dayOfMonth} of each month at ${schedule.timeOfDay}`; if (schedule.type === "interval") { const minutes = schedule.everyMs / 60_000; return Number.isInteger(minutes) @@ -1213,6 +1216,8 @@ function ScheduledTaskEditorForm({ } const schedule = scheduleFromDraft(draft); if (schedule === null) { + if (draft.scheduleMode === "monthly") + reportFailure("Invalid monthly schedule", "Choose a day from 1 to 31 and a valid time."); for (const error of webhookDraftErrors(draft)) reportFailure(error.title, error.description); return; } @@ -1510,7 +1515,12 @@ function ScheduledTaskEditorForm({ value={[draft.scheduleMode]} onValueChange={(values) => { const mode = values[0] as ScheduleMode | undefined; - if (mode === "fixed" || mode === "interval" || mode === "webhook") + if ( + mode === "fixed" || + mode === "monthly" || + mode === "interval" || + mode === "webhook" + ) setDraft((current) => ({ ...current, scheduleMode: mode, @@ -1521,7 +1531,8 @@ function ScheduledTaskEditorForm({ })); }} > - At a time + Weekly + Monthly Every interval On webhook @@ -1697,42 +1708,75 @@ function ScheduledTaskEditorForm({ ) : null} - ) : draft.scheduleMode === "fixed" ? ( -
-
- - - setDraft((current) => ({ ...current, timeOfDay: event.target.value })) - } - /> - on + ) : draft.scheduleMode === "fixed" || draft.scheduleMode === "monthly" ? ( +
+
+
+ + + setDraft((current) => ({ ...current, timeOfDay: event.target.value })) + } + /> + {draft.scheduleMode === "fixed" ? ( + on + ) : null} +
+ {draft.scheduleMode === "monthly" ? ( +
+ + +
+ ) : ( + { + if (values.length === 0) return; + setDraft((current) => ({ + ...current, + weekdays: new Set(values.map(Number)), + })); + }} + > + {WEEKDAY_ORDER.map((day) => ( + + {WEEKDAY_SHORT[day]} + + ))} + + )}
- { - if (values.length === 0) return; - setDraft((current) => ({ - ...current, - weekdays: new Set(values.map(Number)), - })); - }} - > - {WEEKDAY_ORDER.map((day) => ( - - {WEEKDAY_SHORT[day]} - - ))} - + {draft.scheduleMode === "monthly" ? ( +

+ Uses the environment's time zone. Months without this day are skipped. +

+ ) : null}
) : (
diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 750f31f2229a5..b497956d76b76 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -151,6 +151,24 @@ describe("editing scheduled task branch settings", () => { }); }); +describe("monthly scheduled tasks", () => { + it("round-trips the monthly day and time without weekday restrictions", () => { + const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; + const draft = taskToDraft({ ...legacyTask, schedule }); + expect(draft.scheduleMode).toBe("monthly"); + expect(draft.dayOfMonth).toBe("31"); + expect(scheduleFromDraft(draft)).toEqual(schedule); + expect(scheduleFromDraft({ ...draft, scheduleMode: "fixed" })).toEqual({ + type: "fixed_time", + timeOfDay: "17:45", + }); + for (const dayOfMonth of ["", "0", "32", "1.5", "abc"]) { + expect(scheduleFromDraft({ ...draft, dayOfMonth })).toBeNull(); + } + expect(scheduleFromDraft({ ...draft, timeOfDay: "24:00" })).toBeNull(); + }); +}); + describe("webhook scheduled tasks", () => { const signature = { header: "x-signature", encoding: "base64", prefix: "" } as const; const webhookTask: ScheduledTask = { diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 218e2a6e683b0..0aadcc3dbcd13 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -52,7 +52,7 @@ export function validateScheduledTasksSearch(raw: Record) { }; } -export type ScheduleMode = "fixed" | "interval" | "webhook"; +export type ScheduleMode = "fixed" | "monthly" | "interval" | "webhook"; export type WorkspaceMode = "root" | "worktree" | "existing_worktree"; export interface DraftState { @@ -63,6 +63,7 @@ export interface DraftState { readonly scheduleMode: ScheduleMode; readonly intervalMinutes: string; readonly timeOfDay: string; + readonly dayOfMonth: string; readonly weekdays: ReadonlySet; readonly projectId: string; readonly threadId: string; @@ -186,6 +187,12 @@ export function scheduleFromDraft(draft: DraftState): ScheduledTaskUpsertSchedul const everyMs = Math.round(Number(draft.intervalMinutes) * 60_000); return { type: "interval", everyMs }; } + if (draft.scheduleMode === "monthly") { + const dayOfMonth = Number(draft.dayOfMonth); + if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null; + if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay)) return null; + return { type: "monthly", timeOfDay: draft.timeOfDay, dayOfMonth }; + } const selectedEveryDay = draft.weekdays.size === 0 || draft.weekdays.size === 7; return { type: "fixed_time", @@ -206,11 +213,12 @@ export function taskToDraft(task: ScheduledTask): DraftState { title: task.title, prompt: task.prompt, enabled: task.enabled, - scheduleMode: - schedule.type === "interval" ? "interval" : schedule.type === "webhook" ? "webhook" : "fixed", + scheduleMode: schedule.type === "fixed_time" ? "fixed" : schedule.type, intervalMinutes: schedule.type === "interval" ? String(Math.max(1, schedule.everyMs / 60_000)) : "15", - timeOfDay: schedule.type === "fixed_time" ? schedule.timeOfDay : "09:00", + timeOfDay: + schedule.type === "fixed_time" || schedule.type === "monthly" ? schedule.timeOfDay : "09:00", + dayOfMonth: schedule.type === "monthly" ? String(schedule.dayOfMonth) : "1", weekdays, projectId: task.projectId, threadId: task.threadId ?? "", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 905b323320a55..b675a6fdc34b0 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -66,7 +66,9 @@ Open **Settings → Scheduled tasks** to create recurring tasks or manage existi ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your phone's. Choose **Monthly** +to run on a specific day of the month at a set time. Months without that day are +skipped, so a task set to the 31st does not run in February or April. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index df65ee6ca320e..6fe8d5fc6fb32 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -58,7 +58,7 @@ const OrchestratorMcpSchedule = Schema.Union([ OrchestratorMcpScheduleFromJsonString, ]).annotate({ description: - "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, or {type:'webhook'}.", + "Trigger: {type:'interval',everyMs}, {type:'fixed_time',timeOfDay,weekdays?}, {type:'monthly',timeOfDay,dayOfMonth}, or {type:'webhook'}. Calendar times use the environment's time zone.", }); const OrchestratorMcpTargetOptionsFromRecord = Schema.Record( diff --git a/packages/contracts/src/scheduledTask.test.ts b/packages/contracts/src/scheduledTask.test.ts index 50a49de7f867d..9b765015af5a6 100644 --- a/packages/contracts/src/scheduledTask.test.ts +++ b/packages/contracts/src/scheduledTask.test.ts @@ -11,6 +11,19 @@ const decodeSchedule = Schema.decodeUnknownSync(ScheduledTaskSchedule); const decodeUpsertSchedule = Schema.decodeUnknownSync(ScheduledTaskUpsertSchedule); describe("ScheduledTaskSchedule", () => { + it("accepts monthly dates and rejects invalid days and times", () => { + for (const decode of [decodeSchedule, decodeUpsertSchedule]) { + for (const dayOfMonth of [1, 15, 31]) { + const schedule = { type: "monthly", timeOfDay: "09:00", dayOfMonth }; + expect(decode(schedule)).toEqual(schedule); + } + for (const dayOfMonth of [0, 32, 1.5, "15", undefined]) { + expect(() => decode({ type: "monthly", timeOfDay: "09:00", dayOfMonth })).toThrow(); + } + expect(() => decode({ type: "monthly", timeOfDay: "24:00", dayOfMonth: 1 })).toThrow(); + } + }); + it("keeps legacy sub-minute persisted schedules readable", () => { expect( decodeSchedule({ diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 5d8e4d29d68ad..26a52abcb96cd 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -45,6 +45,14 @@ const ScheduledTaskFixedTimeSchedule = Schema.Struct({ ), }); +const ScheduledTaskMonthlySchedule = Schema.Struct({ + type: Schema.Literal("monthly"), + timeOfDay: TimeOfDay, + dayOfMonth: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 31 })).annotate({ + description: "Day of the month. Months without this day are skipped.", + }), +}); + const ScheduledTaskWebhookSignatureFields = { header: TrimmedNonEmptyString.annotate({ description: "Header with the signature, such as x-hub-signature-256.", @@ -166,6 +174,7 @@ const ScheduledTaskUpsertWebhookSchedule = Schema.Struct({ export const ScheduledTaskSchedule = Schema.Union([ ScheduledTaskIntervalSchedule, ScheduledTaskFixedTimeSchedule, + ScheduledTaskMonthlySchedule, ScheduledTaskWebhookSchedule, ]); export type ScheduledTaskSchedule = typeof ScheduledTaskSchedule.Type; @@ -180,6 +189,7 @@ export const ScheduledTaskUpsertSchedule = Schema.Union([ }), }), ScheduledTaskFixedTimeSchedule, + ScheduledTaskMonthlySchedule, ScheduledTaskUpsertWebhookSchedule, ]); export type ScheduledTaskUpsertSchedule = typeof ScheduledTaskUpsertSchedule.Type; From e3fcc19cb03c1e73e3e3b6b05dfd698cfacad7ee Mon Sep 17 00:00:00 2001 From: rolaca11 Date: Tue, 27 Oct 2026 10:26:29 +0100 Subject: [PATCH 2/2] fix(scheduled-tasks): gate monthly schedules on server support --- .../SettingsScheduledTasksRouteScreen.tsx | 25 ++++++- .../settings/scheduledTaskDraft.test.ts | 19 +++++- .../features/settings/scheduledTaskDraft.ts | 6 +- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../ScheduledTasksSettings.browser.test.tsx | 67 +++++++++++++++++-- .../settings/ScheduledTasksSettings.tsx | 35 ++++++++-- .../scheduledTasksSettings.logic.test.ts | 16 ++++- .../settings/scheduledTasksSettings.logic.ts | 6 +- packages/contracts/src/environment.test.ts | 12 ++++ packages/contracts/src/environment.ts | 1 + 11 files changed, 169 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index b2ea6cf789bfb..971962bb8030c 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -653,6 +653,8 @@ function TaskForm({ ); const projects = useProjects().filter((project) => project.environmentId === environmentId); const config = useEnvironmentServerConfig(environmentId); + const supportsMonthly = config?.environment.capabilities.monthlyScheduledTasks === true; + const monthlyUnsupported = draft.schedule.mode === "monthly" && !supportsMonthly; const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); const canOperate = useAtomValue( serverEnvironment.upsertScheduledTask.permissionAtom(environmentId), @@ -687,12 +689,20 @@ function TaskForm({ environmentUnavailable ) return; + if (monthlyUnsupported) { + Dialog.alert( + "Monthly scheduling unavailable", + "Update T3 Code on this environment to save monthly schedules.", + ); + return; + } // Preserve signatures edited on other clients by sending the task's current value. const liveTask = tasks.data?.tasks.find((task) => task.id === draft.task?.id); const schedule = scheduleFromDraft( draft.schedule.mode === "webhook" && liveTask?.schedule.type === "webhook" ? { ...draft.schedule, signature: liveTask.schedule.signature } : draft.schedule, + supportsMonthly, ); if ( draft.schedule.mode === "webhook" && @@ -939,7 +949,7 @@ function TaskForm({ + {monthlyUnsupported ? ( + + Update T3 Code on this environment to save monthly schedules. + + ) : null} {draft.schedule.mode === "fixed_time" || draft.schedule.mode === "monthly" ? ( <> void save()} label={saving ? "Saving…" : draft.task ? "Save changes" : "Create task"} /> diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts index 33fd011a41a62..86f5d5943ea64 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.test.ts @@ -16,15 +16,28 @@ import { } from "./scheduledTaskDraft"; describe("scheduleDraftForTask", () => { + it("refuses a monthly payload for an unsupported destination without changing the draft", () => { + const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; + const draft = scheduleDraftForTask({ schedule }); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); + expect(scheduleFromDraft(draft, false)).toBeNull(); + expect(scheduleFromDraft(draft)).toBeNull(); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); + expect(scheduleFromDraft({ ...DEFAULT_SCHEDULE, mode: "interval" }, false)).toEqual({ + type: "interval", + everyMs: 900_000, + }); + }); + it("round-trips monthly schedules and tracks edits to their day", () => { const schedule = { type: "monthly", dayOfMonth: 31, timeOfDay: "17:45" } as const; const draft = scheduleDraftForTask({ schedule }); expect(draft.mode).toBe("monthly"); - expect(scheduleFromDraft(draft)).toEqual(schedule); + expect(scheduleFromDraft(draft, true)).toEqual(schedule); for (const dayOfMonth of ["", "0", "32", "1.5", "abc"]) { - expect(scheduleFromDraft({ ...draft, dayOfMonth })).toBeNull(); + expect(scheduleFromDraft({ ...draft, dayOfMonth }, true)).toBeNull(); } - expect(scheduleFromDraft({ ...draft, timeOfDay: "24:00" })).toBeNull(); + expect(scheduleFromDraft({ ...draft, timeOfDay: "24:00" }, true)).toBeNull(); const initial = { ...createDraft(null, null), schedule: draft }; expect(hasScheduledTaskDraftChanges(initial, initial)).toBe(false); expect( diff --git a/apps/mobile/src/features/settings/scheduledTaskDraft.ts b/apps/mobile/src/features/settings/scheduledTaskDraft.ts index b6d208896bc72..cb92ae1d8ee44 100644 --- a/apps/mobile/src/features/settings/scheduledTaskDraft.ts +++ b/apps/mobile/src/features/settings/scheduledTaskDraft.ts @@ -125,7 +125,10 @@ export function scheduleDraftForTask(task: Pick): Sch } } -export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSchedule | null { +export function scheduleFromDraft( + draft: ScheduleDraft, + supportsMonthly = false, +): ScheduledTaskUpsertSchedule | null { if (draft.mode === "webhook") { const maxDeliveryAgeMinutes = parseMaxDeliveryAge(draft.maxDeliveryAgeMinutes); if (maxDeliveryAgeMinutes === undefined) return null; @@ -177,6 +180,7 @@ export function scheduleFromDraft(draft: ScheduleDraft): ScheduledTaskUpsertSche return minutes >= 1 && Number.isSafeInteger(everyMs) ? { type: "interval", everyMs } : null; } if (draft.mode === "monthly") { + if (!supportsMonthly) return null; const dayOfMonth = Number(draft.dayOfMonth); if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) return null; if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(draft.timeOfDay)) return null; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..d53858a3afd3e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -213,6 +213,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.orchestrationProtocolVersion).toBe(ORCHESTRATION_PROTOCOL_VERSION); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.monthlyScheduledTasks).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 4678aff95b736..2ed57aec28d81 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -216,6 +216,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + monthlyScheduledTasks: true, attachmentUploads: true, questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..0f5a076ff6369 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; +import { createContext, useContext } from "react"; import "~/index.css"; import { render } from "../../../test/render"; @@ -39,13 +40,27 @@ const task: ScheduledTask = { }; const state = vi.hoisted(() => ({ connected: true, tasksLoaded: true })); +const MonthlySchedulingContext = createContext(undefined); -function environment() { +function setup() { + state.connected = true; + state.tasksLoaded = true; +} + +function environment(monthlySupported?: boolean) { return { environmentId, label: "Laptop", connection: { phase: state.connected ? "connected" : "disconnected" }, - serverConfig: state.connected ? { environment: { platform: "linux" } } : null, + serverConfig: state.connected + ? { + environment: { + platform: "linux", + capabilities: + monthlySupported === undefined ? {} : { monthlyScheduledTasks: monthlySupported }, + }, + } + : null, }; } @@ -93,7 +108,7 @@ vi.mock(import("../../state/query"), async (original) => ({ })); vi.mock(import("../../state/environments"), async (original) => ({ ...(await original()), - useEnvironment: (() => environment()) as never, + useEnvironment: (() => environment(useContext(MonthlySchedulingContext))) as never, })); vi.mock(import("../../state/entities"), async (original) => ({ ...(await original()), @@ -106,8 +121,7 @@ vi.mock(import("../../hooks/useSettings"), async (original) => ({ describe("ScheduledTasksSettings", () => { it("keeps a linked task's unsaved edits across a disconnect and reconnect", async () => { - state.connected = true; - state.tasksLoaded = true; + setup(); await using screen = await render(); const name = page.getByRole("dialog").getByRole("textbox", { name: "Name" }); await expect.element(name).toHaveValue("Review issues"); @@ -124,7 +138,7 @@ describe("ScheduledTasksSettings", () => { }); it("keeps an editor the user opened when the linked task loads later", async () => { - state.connected = true; + setup(); state.tasksLoaded = false; await using screen = await render(); await userEvent.click(page.getByRole("button", { name: "New task" })); @@ -138,4 +152,45 @@ describe("ScheduledTasksSettings", () => { await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument(); await expect.element(name).toHaveValue("My draft"); }); + + it("offers monthly scheduling only when supported and preserves a draft when support is lost", async () => { + setup(); + await using screen = await render( + + + , + ); + const dialog = page.getByRole("dialog"); + const monthly = dialog.getByRole("button", { name: "Monthly", exact: true }); + await expect.element(monthly).not.toBeInTheDocument(); + + await screen.rerender( + + + , + ); + await userEvent.click(monthly); + const day = dialog.getByRole("combobox", { name: "Day of month" }); + await userEvent.click(day); + await userEvent.click(page.getByRole("option", { name: "31", exact: true })); + const name = dialog.getByRole("textbox", { name: "Name" }); + await userEvent.fill(name, "Monthly review"); + + await screen.rerender( + + + , + ); + await expect.element(monthly).toBeDisabled(); + await expect.element(day).toHaveTextContent("31"); + await expect.element(name).toHaveValue("Monthly review"); + await expect + .element(dialog.getByText("Update T3 Code on this environment to save monthly schedules.")) + .toBeVisible(); + const save = dialog.getByRole("button", { name: "Save task" }); + await expect.element(save).toBeDisabled(); + + await userEvent.click(dialog.getByRole("button", { name: "Weekly", exact: true })); + await expect.element(save).toBeEnabled(); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 9f2340cbe2106..c36bb70ba325e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1123,6 +1123,8 @@ function ScheduledTaskEditorForm({ const environment = useEnvironment(environmentId); const connected = environment?.connection.phase === "connected" && environment.serverConfig !== null; + const supportsMonthly = + environment?.serverConfig?.environment.capabilities.monthlyScheduledTasks === true; const tasksQuery = useEnvironmentQuery( connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); @@ -1144,6 +1146,7 @@ function ScheduledTaskEditorForm({ const [draft, setDraft] = useState(() => task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, ); + const monthlyUnsupported = draft.scheduleMode === "monthly" && !supportsMonthly; const submissionPending = useRef(false); const editingTaskMissing = draft.editingId !== null && @@ -1214,7 +1217,14 @@ function ScheduledTaskEditorForm({ reportFailure("Invalid filter", parsedFilters.error); return; } - const schedule = scheduleFromDraft(draft); + if (monthlyUnsupported) { + reportFailure( + "Monthly scheduling unavailable", + "Update T3 Code on this environment to save monthly schedules.", + ); + return; + } + const schedule = scheduleFromDraft(draft, supportsMonthly); if (schedule === null) { if (draft.scheduleMode === "monthly") reportFailure("Invalid monthly schedule", "Choose a day from 1 to 31 and a valid time."); @@ -1517,7 +1527,7 @@ function ScheduledTaskEditorForm({ const mode = values[0] as ScheduleMode | undefined; if ( mode === "fixed" || - mode === "monthly" || + (mode === "monthly" && supportsMonthly) || mode === "interval" || mode === "webhook" ) @@ -1532,12 +1542,22 @@ function ScheduledTaskEditorForm({ }} > Weekly - Monthly + {supportsMonthly || draft.scheduleMode === "monthly" ? ( + + Monthly + + ) : null} Every interval On webhook
+ {monthlyUnsupported ? ( +

+ Update T3 Code on this environment to save monthly schedules. +

+ ) : null} + {draft.scheduleMode === "webhook" ? (
@@ -1821,7 +1841,14 @@ function ScheduledTaskEditorForm({