Lifts the two hard restrictions in PR #18: * window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439) * maintenance window was exactly 1 hour → now any duration in [1, 180] minutes ((maint_to - maint_from) mod 1440) ## Schema migration (additive) `_migrate_schema()` detects legacy "hours" rows (any row where MAX of the 6 window columns is ≤ 23) and multiplies each column by 60 to convert to minutes. Idempotent — post-conversion values are well above 23 so the guard never fires twice. ## Touched surfaces - `models/schedule_type.py` — column comments updated; new `compute_maintenance_duration()` helper (returns 1-1440 min, treats from==to as 1440 which is then rejected by validator) - `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440; `_validate_maintenance_window` accepts 1-180min duration; response includes derived `maintenance_duration_minutes` - `schemas/schedule_type_special_slot.py` — `minute_in_window` max 59→179; `estimated_duration` max 60→180 - `routers/schedule_type.py` — PATCH re-validates merged maintenance pair (partial updates can put the row into an invalid combo the pydantic single-field validator can't catch); `_attach_derived` populates the new response field - `routers/schedule_type_special_slot.py` — `_validate_fits_window` now takes the parent's maintenance duration instead of hard-coded 60 - `routers/calendar.py` — `_scheduled_inside_window` arg renamed hour→min; the maintenance-window guard error message formats HH:MM not HH:00 - `services/special_slot_materialiser.py` — materialised `scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)` with hour/minute split 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.7 KiB
Python
176 lines
5.7 KiB
Python
"""Materialise schedule_type special slots into per-agent time_slots rows.
|
||
|
||
A ScheduleTypeSpecialSlot is a template — it lives on the schedule_type.
|
||
For an agent on that schedule_type to actually be woken, the system must
|
||
emit a row in `time_slots` with `slot_type=system`, `is_admin_locked=true`,
|
||
`special_slot_id=<template_id>` for the agent's `user_id` on the target
|
||
date. This module is the single materialisation point.
|
||
|
||
Called from:
|
||
* GET /calendar/day — before returning slots, materialise today's special
|
||
slots for the calling user.
|
||
* GET /calendar/sync — before returning per-claw schedules, materialise
|
||
today's special slots for every agent on this claw whose schedule_type
|
||
has any active special slot template.
|
||
|
||
Idempotent: re-running on the same (agent, date, special_slot_template)
|
||
is a no-op — uniqueness is enforced via SELECT-then-insert. We do not add
|
||
a DB-level unique constraint because the time_slots table is already
|
||
indexed by (user_id, date) and an extra composite index is overkill for
|
||
the low cardinality of (agents × special-slot-templates) per day.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date as date_type, time as time_type
|
||
from typing import Iterable
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.agent import Agent
|
||
from app.models.calendar import TimeSlot, SlotType, SlotStatus, EventType
|
||
from app.models.schedule_type import ScheduleType
|
||
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
|
||
|
||
|
||
def materialise_special_slots_for_user(
|
||
db: Session,
|
||
user_id: int,
|
||
target_date: date_type,
|
||
commit: bool = True,
|
||
) -> list[TimeSlot]:
|
||
"""Materialise today's special slots for one agent (identified by user_id).
|
||
|
||
Returns the list of newly created rows (may be empty if all already exist
|
||
or the agent has no schedule_type / no active templates).
|
||
"""
|
||
agent = db.query(Agent).filter(Agent.user_id == user_id).first()
|
||
if not agent or not agent.schedule_type_id:
|
||
return []
|
||
|
||
return _materialise_for_agent(db, agent, target_date, commit=commit)
|
||
|
||
|
||
def materialise_special_slots_for_claw(
|
||
db: Session,
|
||
claw_identifier: str,
|
||
target_date: date_type,
|
||
commit: bool = True,
|
||
) -> list[TimeSlot]:
|
||
"""Materialise today's special slots for every agent on a claw instance.
|
||
|
||
Used by the multi-agent `/calendar/sync` endpoint so plugin-driven
|
||
`runSync` cycles see the special slots without each agent having to
|
||
hit `/calendar/day` first.
|
||
"""
|
||
agents = (
|
||
db.query(Agent)
|
||
.filter(
|
||
Agent.claw_identifier == claw_identifier,
|
||
Agent.schedule_type_id.isnot(None),
|
||
)
|
||
.all()
|
||
)
|
||
created: list[TimeSlot] = []
|
||
for agent in agents:
|
||
created.extend(_materialise_for_agent(db, agent, target_date, commit=False))
|
||
if commit and created:
|
||
db.commit()
|
||
return created
|
||
|
||
|
||
def _materialise_for_agent(
|
||
db: Session,
|
||
agent: Agent,
|
||
target_date: date_type,
|
||
commit: bool,
|
||
) -> list[TimeSlot]:
|
||
st: ScheduleType | None = (
|
||
db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
|
||
)
|
||
if not st:
|
||
return []
|
||
|
||
templates: Iterable[ScheduleTypeSpecialSlot] = (
|
||
db.query(ScheduleTypeSpecialSlot)
|
||
.filter(
|
||
ScheduleTypeSpecialSlot.schedule_type_id == st.id,
|
||
ScheduleTypeSpecialSlot.is_active.is_(True),
|
||
)
|
||
.all()
|
||
)
|
||
|
||
created: list[TimeSlot] = []
|
||
for tpl in templates:
|
||
if _already_materialised(db, agent.user_id, target_date, tpl.id):
|
||
continue
|
||
slot = _build_time_slot_from_template(
|
||
user_id=agent.user_id,
|
||
target_date=target_date,
|
||
schedule_type=st,
|
||
template=tpl,
|
||
)
|
||
db.add(slot)
|
||
created.append(slot)
|
||
|
||
if commit and created:
|
||
db.commit()
|
||
for slot in created:
|
||
db.refresh(slot)
|
||
return created
|
||
|
||
|
||
def _already_materialised(
|
||
db: Session,
|
||
user_id: int,
|
||
target_date: date_type,
|
||
template_id: int,
|
||
) -> bool:
|
||
return (
|
||
db.query(TimeSlot.id)
|
||
.filter(
|
||
TimeSlot.user_id == user_id,
|
||
TimeSlot.date == target_date,
|
||
TimeSlot.special_slot_id == template_id,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
|
||
|
||
def _build_time_slot_from_template(
|
||
*,
|
||
user_id: int,
|
||
target_date: date_type,
|
||
schedule_type: ScheduleType,
|
||
template: ScheduleTypeSpecialSlot,
|
||
) -> TimeSlot:
|
||
# schedule_type.maintenance_from is minutes-since-UTC-midnight; the
|
||
# template's minute_in_window is an offset inside that window. Combined
|
||
# offset must fit in [0, 1440) and produce a wall-clock time_type.
|
||
total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
|
||
scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0)
|
||
# Merge admin-supplied event_data with bookkeeping pointers so the
|
||
# agent (and ARD) can identify the template at wake time.
|
||
merged_event_data = dict(template.event_data or {})
|
||
merged_event_data.setdefault("source", "schedule_type_special_slot")
|
||
merged_event_data["special_slot_id"] = template.id
|
||
merged_event_data["special_slot_name"] = template.name
|
||
merged_event_data["schedule_type_id"] = schedule_type.id
|
||
merged_event_data["schedule_type_name"] = schedule_type.name
|
||
|
||
return TimeSlot(
|
||
user_id=user_id,
|
||
date=target_date,
|
||
slot_type=SlotType.SYSTEM,
|
||
estimated_duration=template.estimated_duration,
|
||
scheduled_at=scheduled_at,
|
||
attended=False,
|
||
event_type=EventType.SYSTEM_EVENT,
|
||
event_data=merged_event_data,
|
||
priority=template.priority,
|
||
status=SlotStatus.NOT_STARTED,
|
||
is_admin_locked=True,
|
||
special_slot_id=template.id,
|
||
)
|