diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index a9f2228f..42c2f702 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -43,7 +43,7 @@ PRESETS = { "weekdays_morning": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1, 2, 3, 4, 5]}, "weekly_monday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": [1]}, "weekly_friday": {"enabled": True, "repeat_unit": "week", "repeat_every": 1, "hour": 17, "minute": 0, "on_days": [5]}, - "monthly_first": {"enabled": True, "repeat_unit": "month", "repeat_every": 1, "hour": 9, "minute": 0, "on_days": []}, + "monthly_first": {"enabled": True, "repeat_unit": "month", "repeat_every": 1, "hour": 9, "minute": 0, "day_of_month": 1, "on_days": []}, } @@ -88,6 +88,7 @@ TOOLS = [ "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when preset='custom' and repeat_unit='week'.", }, + "day_of_month": {"type": "integer", "description": "Day 1-31 when preset='custom' and repeat_unit='month'. Use 1 for 'first of the month'; values past a shorter month's length clamp to that month's last day."}, "timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles'). Omit to use the user's current local zone at scheduling time."}, "source_session_id": {"type": "string", "description": "Optional; the chat session this workflow was created from. Inherits its tool surface."}, }, @@ -114,6 +115,7 @@ TOOLS = [ "repeat_unit": {"type": "string", "enum": ["minute", "hour", "day", "week", "month"]}, "repeat_every": {"type": "integer", "description": "Interval count for repeat_unit (e.g. 2 with repeat_unit='week' means every other week; 15 with repeat_unit='minute' means every 15 minutes, the minimum)."}, "on_days": {"type": "array", "items": {"type": "integer"}, "description": "Weekdays (Sun=0..Sat=6) when repeat_unit='week'."}, + "day_of_month": {"type": "integer", "description": "Day 1-31 when repeat_unit='month'. Use 1 for 'first of the month'; values past a shorter month's length clamp to that month's last day."}, "timezone": {"type": "string", "description": "IANA timezone name (e.g. 'America/Los_Angeles')."}, }, "required": ["workflow_id"], @@ -322,6 +324,7 @@ def _build_schedule_from_preset(preset: str, args: dict) -> dict: "hour": int(args.get("hour", 9)), "minute": int(args.get("minute", 0)), "on_days": list(args.get("on_days") or []), + "day_of_month": args.get("day_of_month"), } preset_def = PRESETS.get(preset) if not preset_def: @@ -388,7 +391,7 @@ def handle_update(args: dict) -> dict: if "schedule_enabled" in args: sched_patch["enabled"] = bool(args["schedule_enabled"]) sched_dirty = True - for k in ("hour", "minute", "repeat_unit", "on_days", "repeat_every", "timezone"): + for k in ("hour", "minute", "repeat_unit", "on_days", "repeat_every", "day_of_month", "timezone"): if k in args: sched_patch[k] = args[k] sched_dirty = True diff --git a/backend/apps/workflows/models.py b/backend/apps/workflows/models.py index 4814e583..e0f7edc5 100644 --- a/backend/apps/workflows/models.py +++ b/backend/apps/workflows/models.py @@ -26,6 +26,9 @@ class ScheduleConfig(BaseModel): on_days: list[int] = Field(default_factory=list) hour: int = Field(default=9, ge=0, le=23) minute: int = Field(default=0, ge=0, le=59) + # Monthly schedules can pin a day-of-month explicitly. None preserves the + # legacy "same day as the current reference" behavior for older records. + day_of_month: Optional[int] = Field(default=None, ge=1, le=31) # IANA zone name (e.g. "America/Los_Angeles") or "local" for legacy # records that predate explicit tz. storage._load_all_from_disk coerces # "local" to the host zone in memory; we leave it on disk until the diff --git a/backend/apps/workflows/scheduler.py b/backend/apps/workflows/scheduler.py index 8a3f5113..7450ca00 100644 --- a/backend/apps/workflows/scheduler.py +++ b/backend/apps/workflows/scheduler.py @@ -95,16 +95,8 @@ def _as_utc(dt: Optional[datetime]) -> Optional[datetime]: return dt.astimezone(timezone.utc) -def _add_months(dt: datetime, months: int) -> datetime: - """Add months preserving day-of-month, clamping only if the target month - is shorter (e.g. Jan 31 + 1mo → Feb 28/29). Wall-clock arithmetic; the - caller is responsible for tz attachment. - """ - total = dt.month - 1 + months - year = dt.year + total // 12 - month = total % 12 + 1 - day = min(dt.day, calendar.monthrange(year, month)[1]) - return dt.replace(year=year, month=month, day=day) +def _week_start(d: datetime) -> datetime: + return (d - timedelta(days=_js_weekday(d))).replace(hour=0, minute=0, second=0, microsecond=0) def _js_weekday(d: datetime) -> int: @@ -143,33 +135,43 @@ def _next_fire_after(sched: ScheduleConfig, ref_utc: datetime) -> Optional[datet return c.astimezone(timezone.utc) candidate = base.replace(hour=sched.hour, minute=sched.minute) - if candidate <= ref_local: - candidate = candidate + timedelta(days=1) if sched.repeat_unit == "day": step = max(1, sched.repeat_every) - # Walk forward in step-day increments until we find a slot strictly - # after `ref_local`. Cheap because step is small. while candidate <= ref_local: candidate = candidate + timedelta(days=step) return candidate.astimezone(timezone.utc) - if sched.repeat_unit == "week": - allowed = sched.on_days - for _ in range(0, 14): - if _js_weekday(candidate) in allowed and candidate > ref_local: - return candidate.astimezone(timezone.utc) - candidate = candidate + timedelta(days=1) - return candidate.astimezone(timezone.utc) - if sched.repeat_unit == "month": - target_day = ref_local.day + target_day = sched.day_of_month or ref_local.day step = max(1, sched.repeat_every) c = candidate.replace(day=min(target_day, calendar.monthrange(candidate.year, candidate.month)[1])) while c <= ref_local: - c = _add_months(c, step) + total = c.month - 1 + step + year = c.year + total // 12 + month = total % 12 + 1 + day = min(target_day, calendar.monthrange(year, month)[1]) + c = c.replace(year=year, month=month, day=day) return c.astimezone(timezone.utc) + if candidate <= ref_local: + candidate = candidate + timedelta(days=1) + + if sched.repeat_unit == "week": + allowed = sched.on_days + step = max(1, sched.repeat_every) + anchor_week = _week_start(ref_local) + for _ in range(0, 7 * step + 7): + week_delta = (_week_start(candidate).date() - anchor_week.date()).days // 7 + if ( + _js_weekday(candidate) in allowed + and candidate > ref_local + and (week_delta == 0 or week_delta % step == 0) + ): + return candidate.astimezone(timezone.utc) + candidate = candidate + timedelta(days=1) + return candidate.astimezone(timezone.utc) + return None diff --git a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx index 0e2897e7..d4a3c573 100644 --- a/frontend/src/app/pages/Workflows/ScheduleFacet.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleFacet.tsx @@ -71,23 +71,31 @@ function previewNextRun(sched: ScheduleConfig): Date | null { return c; } let candidate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), sched.hour, sched.minute, 0, 0); - if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000); if (sched.repeat_unit === 'day') { const step = Math.max(1, sched.repeat_every); while (candidate <= now) candidate = new Date(candidate.getTime() + step * 86400000); return candidate; } + if (candidate <= now) candidate = new Date(candidate.getTime() + 86400000); if (sched.repeat_unit === 'week') { const allowed = sched.on_days.length ? sched.on_days : [jsWeekday(now)]; - for (let i = 0; i < 14; i += 1) { - if (allowed.includes(jsWeekday(candidate)) && candidate > now) return candidate; + const step = Math.max(1, sched.repeat_every); + const anchorWeek = new Date(now); + anchorWeek.setHours(0, 0, 0, 0); + anchorWeek.setDate(anchorWeek.getDate() - anchorWeek.getDay()); + for (let i = 0; i < 7 * step + 7; i += 1) { + const candidateWeek = new Date(candidate); + candidateWeek.setHours(0, 0, 0, 0); + candidateWeek.setDate(candidateWeek.getDate() - candidateWeek.getDay()); + const weekDelta = Math.floor((candidateWeek.getTime() - anchorWeek.getTime()) / (7 * 86400000)); + if (allowed.includes(jsWeekday(candidate)) && candidate > now && (weekDelta === 0 || weekDelta % step === 0)) return candidate; candidate = new Date(candidate.getTime() + 86400000); } return candidate; } if (sched.repeat_unit === 'month') { const step = Math.max(1, sched.repeat_every); - const startDay = now.getDate(); + const startDay = sched.day_of_month || now.getDate(); let year = now.getFullYear(); let month = now.getMonth(); let guard = 0; @@ -235,6 +243,7 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se // when switching in so the input never shows an invalid value. const patch: Partial = { repeat_unit: unit }; if (unit === 'minute' && s.repeat_every < 15) patch.repeat_every = 15; + if (unit === 'month' && !s.day_of_month) patch.day_of_month = new Date().getDate(); setSched(patch); }} sx={{ fontSize: LABEL_FS, '& .MuiSelect-select': { py: 0.5 } }}> @@ -260,6 +269,17 @@ export default function ScheduleFacet({ draft, setDraft }: { draft: Workflow; se })} )} + {s.repeat_unit === 'month' && ( + + ↳ on day + setSched({ day_of_month: Math.min(31, Math.max(1, Number(e.target.value) || 1)) })} + sx={{ width: 56, fontSize: INPUT_FS, border: `1px solid ${c.border.subtle}`, borderRadius: `${c.radius.md}px`, px: 0.75, py: 0.4 }} + /> + + )} {/* minute-unit schedules have no anchor time; hour-unit schedules only need the minute offset; the rest pick a full clock time. */} {s.repeat_unit === 'hour' && ( diff --git a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx index 49fe6c71..acb659d4 100644 --- a/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx +++ b/frontend/src/app/pages/Workflows/ScheduleThisPopover.tsx @@ -27,7 +27,7 @@ const PRESETS: Preset[] = [ { label: 'Every day at 9am', hint: 'Daily standup, morning report', build: () => ({ enabled: true, repeat_unit: 'day', repeat_every: 1, hour: 9, minute: 0 }) }, { label: 'Weekdays at 9am', hint: 'Mon to Fri', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1, 2, 3, 4, 5], hour: 9, minute: 0 }) }, { label: 'Every Monday at 9am', hint: 'Weekly check-in', build: () => ({ enabled: true, repeat_unit: 'week', repeat_every: 1, on_days: [1], hour: 9, minute: 0 }) }, - { label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, hour: 9, minute: 0 }) }, + { label: 'Every month on the 1st', hint: 'Monthly summary, billing report', build: () => ({ enabled: true, repeat_unit: 'month', repeat_every: 1, day_of_month: 1, hour: 9, minute: 0 }) }, ]; function extractStepsFromSession(session: { messages?: Array<{ role: string; content: unknown; hidden?: boolean }> } | null | undefined): Array<{ id: string; text: string }> { diff --git a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx index e0684204..8b71bbe2 100644 --- a/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx +++ b/frontend/src/app/pages/Workflows/WorkflowCardSubviews.tsx @@ -333,7 +333,10 @@ function describeSchedule(workflow: Workflow): string { if (s.repeat_unit === 'minute') return `Every ${s.repeat_every} minutes`; if (s.repeat_unit === 'hour') return s.repeat_every === 1 ? `Hourly at :${String(s.minute).padStart(2, '0')}` : `Every ${s.repeat_every} hours`; if (s.repeat_unit === 'day') return s.repeat_every === 1 ? `Daily at ${time}` : `Every ${s.repeat_every} days at ${time}`; - if (s.repeat_unit === 'month') return s.repeat_every === 1 ? `Monthly at ${time}` : `Every ${s.repeat_every} months at ${time}`; + if (s.repeat_unit === 'month') { + const day = s.day_of_month ? ` on day ${s.day_of_month}` : ''; + return s.repeat_every === 1 ? `Monthly${day} at ${time}` : `Every ${s.repeat_every} months${day} at ${time}`; + } if (s.on_days.length === 5 && [1,2,3,4,5].every((d) => s.on_days.includes(d))) return `Weekdays at ${time}`; if (s.on_days.length === 2 && [0,6].every((d) => s.on_days.includes(d))) return `Weekends at ${time}`; if (s.on_days.length === 1) { diff --git a/frontend/src/app/pages/Workflows/scheduleUtils.ts b/frontend/src/app/pages/Workflows/scheduleUtils.ts index 921dc3eb..1e7b2c9a 100644 --- a/frontend/src/app/pages/Workflows/scheduleUtils.ts +++ b/frontend/src/app/pages/Workflows/scheduleUtils.ts @@ -17,6 +17,7 @@ export function defaultSchedule(): ScheduleConfig { on_days: [], hour: 9, minute: 0, + day_of_month: null, timezone: tz, ends_at: null, max_runs: null, @@ -84,7 +85,8 @@ export function describeSchedule(sched: ScheduleConfig): string { return sched.repeat_every === 1 ? `Every day at ${time}` : `Every ${sched.repeat_every} days at ${time}`; } if (sched.repeat_unit === 'month') { - return sched.repeat_every === 1 ? `Every month at ${time}` : `Every ${sched.repeat_every} months at ${time}`; + const day = sched.day_of_month ? ` on day ${sched.day_of_month}` : ''; + return sched.repeat_every === 1 ? `Every month${day} at ${time}` : `Every ${sched.repeat_every} months${day} at ${time}`; } const days = sched.on_days.length === 0 ? 'week' : sched.on_days .slice() @@ -198,7 +200,7 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = } if (sched.repeat_unit === 'month') { - const startDay = from.getDate(); + const startDay = sched.day_of_month || from.getDate(); let year = from.getFullYear(); let month = from.getMonth(); let guard = 0; @@ -217,10 +219,17 @@ export function fireTimesWithin(workflow: Workflow, from: Date, to: Date, cap = const allowed = sched.on_days; if (allowed.length === 0) return []; + const stepWeeks = Math.max(1, sched.repeat_every); + const anchorWeek = new Date(cursor); + anchorWeek.setDate(anchorWeek.getDate() - anchorWeek.getDay()); for (let i = 0; i < 60 && out.length < effectiveCap; i += 1) { const day = new Date(cursor); day.setDate(day.getDate() + i); + const candidateWeek = new Date(day); + candidateWeek.setDate(candidateWeek.getDate() - candidateWeek.getDay()); + const weekDelta = Math.floor((candidateWeek.getTime() - anchorWeek.getTime()) / (7 * 86400000)); if (!allowed.includes(day.getDay())) continue; + if (weekDelta !== 0 && weekDelta % stepWeeks !== 0) continue; day.setHours(sched.hour, sched.minute, 0, 0); if (day >= from && day <= to) out.push(day); if (day > to) break; diff --git a/frontend/src/app/pages/Workflows/workflowVisuals.tsx b/frontend/src/app/pages/Workflows/workflowVisuals.tsx index eb7af1e1..8ac06a43 100644 --- a/frontend/src/app/pages/Workflows/workflowVisuals.tsx +++ b/frontend/src/app/pages/Workflows/workflowVisuals.tsx @@ -100,7 +100,8 @@ function scheduleShort(sched: ScheduleConfig): string { return sched.repeat_every === 1 ? `Daily ${time}` : `Every ${sched.repeat_every}d ${time}`; } if (sched.repeat_unit === 'month') { - return sched.repeat_every === 1 ? `Monthly ${time}` : `Every ${sched.repeat_every}mo ${time}`; + const day = sched.day_of_month ? ` day ${sched.day_of_month}` : ''; + return sched.repeat_every === 1 ? `Monthly${day} ${time}` : `Every ${sched.repeat_every}mo${day} ${time}`; } if (sched.on_days.length === 5 && [1, 2, 3, 4, 5].every((d) => sched.on_days.includes(d))) return `Weekdays ${time}`; if (sched.on_days.length === 2 && [0, 6].every((d) => sched.on_days.includes(d))) return `Weekends ${time}`; diff --git a/frontend/src/shared/state/workflowsSlice.ts b/frontend/src/shared/state/workflowsSlice.ts index 4358de95..aecc2f1f 100644 --- a/frontend/src/shared/state/workflowsSlice.ts +++ b/frontend/src/shared/state/workflowsSlice.ts @@ -18,6 +18,7 @@ export interface ScheduleConfig { on_days: number[]; hour: number; minute: number; + day_of_month?: number | null; timezone: string; /** End conditions; null on both = forever. Scheduler auto-disables on threshold. */ ends_at: string | null;