[aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals

This commit is contained in:
abccodes
2026-06-20 17:55:19 -07:00
parent 6229463ce4
commit 6c399038d6
9 changed files with 77 additions and 35 deletions
+5 -2
View File
@@ -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
+3
View File
@@ -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
+26 -24
View File
@@ -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
@@ -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<ScheduleConfig> = { 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
})}
</Box>
)}
{s.repeat_unit === 'month' && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pl: 12, flexWrap: 'wrap' }}>
<Typography sx={{ fontSize: LABEL_FS, color: c.text.muted }}> on day</Typography>
<InputBase
type="number"
value={s.day_of_month || new Date().getDate()}
onChange={(e) => 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 }}
/>
</Box>
)}
{/* 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' && (
@@ -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 }> {
@@ -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) {
@@ -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;
@@ -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}`;
@@ -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;