"""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=` 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: scheduled_at = time_type( hour=schedule_type.maintenance_from, minute=template.minute_in_window, 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, )