mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-11 20:27:44 +02:00
[aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user