"""Recurring task rule validation, date calculation, and scheduled spawning.""" import calendar import logging from datetime import date, datetime, timedelta, timezone from sqlalchemy import and_, select from scribe.models import async_session from scribe.models.note import Note, TaskStatus logger = logging.getLogger(__name__) _VALID_UNITS = {"day", "week", "month", "year"} def validate_recurrence_rule(rule: dict) -> None: """Raise ValueError if rule is structurally invalid.""" if not isinstance(rule, dict): raise ValueError("recurrence_rule must be an object") rule_type = rule.get("type") if rule_type not in ("interval", "calendar"): raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'") unit = rule.get("unit") if unit not in _VALID_UNITS: raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}") if rule_type == "interval": every = rule.get("every") if not isinstance(every, int) or every < 1: raise ValueError("recurrence_rule.every must be a positive integer") elif rule_type == "calendar": day = rule.get("day_of_month") if not isinstance(day, int) or not 1 <= day <= 31: raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31") if unit == "year": month = rule.get("month") if not isinstance(month, int) or not 1 <= month <= 12: raise ValueError("recurrence_rule.month must be an integer between 1 and 12") elif unit != "month": raise ValueError("calendar recurrence only supports unit 'month' or 'year'") def _add_months(d: date, months: int) -> date: month = d.month - 1 + months year = d.year + month // 12 month = month % 12 + 1 day = min(d.day, calendar.monthrange(year, month)[1]) return d.replace(year=year, month=month, day=day) def _next_day_of_month(from_date: date, day: int) -> date: """Return the next occurrence of day-of-month strictly after from_date.""" year, month = from_date.year, from_date.month max_day = calendar.monthrange(year, month)[1] clamped = min(day, max_day) if clamped > from_date.day: return from_date.replace(day=clamped) # Advance one month if month == 12: year, month = year + 1, 1 else: month += 1 return date(year, month, min(day, calendar.monthrange(year, month)[1])) def _next_annual_date(from_date: date, month: int, day: int) -> date: """Return the next occurrence of month/day after from_date.""" max_day = calendar.monthrange(from_date.year, month)[1] candidate = date(from_date.year, month, min(day, max_day)) if candidate > from_date: return candidate return date( from_date.year + 1, month, min(day, calendar.monthrange(from_date.year + 1, month)[1]), ) def calculate_next_due(rule: dict, from_date: date) -> date: """Return the next due date after from_date according to rule.""" rule_type = rule["type"] unit = rule["unit"] if rule_type == "interval": every = rule["every"] if unit == "day": return from_date + timedelta(days=every) if unit == "week": return from_date + timedelta(weeks=every) if unit == "month": return _add_months(from_date, every) if unit == "year": return _add_months(from_date, every * 12) elif rule_type == "calendar": if unit == "month": return _next_day_of_month(from_date, rule["day_of_month"]) if unit == "year": return _next_annual_date(from_date, rule["month"], rule["day_of_month"]) raise ValueError(f"Unhandled recurrence rule: {rule!r}") async def spawn_recurring_tasks() -> int: """Create child tasks for all recurring tasks whose spawn date has arrived. Returns the number of tasks spawned. """ from scribe.services.notes import create_note now = datetime.now(timezone.utc) spawned = 0 async with async_session() as session: result = await session.execute( select(Note).where( and_( Note.recurrence_rule.isnot(None), Note.recurrence_next_spawn_at <= now, # Never spawn children off a trashed parent — that would # resurrect work the user explicitly deleted. Note.deleted_at.is_(None), ) ) ) tasks = result.scalars().all() for task in tasks: base_date = task.due_date or now.date() next_due = calculate_next_due(task.recurrence_rule, base_date) try: await create_note( task.user_id, title=task.title, body=task.body or "", status=TaskStatus.todo.value, priority=task.priority or "none", due_date=next_due, tags=list(task.tags or []), project_id=task.project_id, milestone_id=task.milestone_id, recurrence_rule=task.recurrence_rule, ) # No explicit embed here: create_note calls embed_note itself, so # the spawn path stopped being a second copy of the embedding rule # the moment that moved into the service (#2056, #280). except Exception: logger.exception("Failed to spawn recurring task %d", task.id) continue async with async_session() as session: parent = await session.get(Note, task.id) if parent: parent.recurrence_next_spawn_at = None await session.commit() spawned += 1 logger.info("Spawned recurring task %d → child due %s", task.id, next_due) return spawned