156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""Recurring task rule validation, date calculation, and scheduled spawning."""
|
|
import asyncio
|
|
import calendar
|
|
import logging
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import and_, select
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.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 fabledassistant.services.embeddings import upsert_note_embedding
|
|
from fabledassistant.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,
|
|
)
|
|
)
|
|
)
|
|
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:
|
|
child = 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,
|
|
)
|
|
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
|
|
if text:
|
|
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
|
|
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
|