## What this adds
1. **Maintenance window on ScheduleType**
- New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
- Invariant: window is exactly 1 hour (validated in pydantic;
maintenance_to must equal (maintenance_from + 1) % 24)
- Default applied via additive migration: 8:00-9:00 UTC for existing
rows so deployments don't crash on first boot
2. **ScheduleTypeSpecialSlot** — admin-managed slot template
- New table schedule_type_special_slots
- Admin (schedule_type.manage) CRUD via
/schedule-types/{id}/special-slots
- Fields: name, description, minute_in_window (0-59 inside the
parent maintenance window), estimated_duration, priority,
event_data (JSON merged into materialised slot), is_active
- Unique constraint (schedule_type_id, name) — name is the stable
human-readable identifier per cohort
3. **Per-agent materialisation**
- New service app/services/special_slot_materialiser.py
- GET /calendar/sync calls materialise_special_slots_for_claw
(idempotent, one row per agent per template per date)
- GET /calendar/day calls materialise_special_slots_for_user
- Materialised rows are slot_type=system, event_type=system_event,
is_admin_locked=true, special_slot_id pointing back to template
- Plugin's runSync picks them up like any other due slot via the
normal real-slots query path
4. **Admin-locked enforcement**
- New TimeSlot columns: is_admin_locked, special_slot_id (FK to
schedule_type_special_slots, ON DELETE SET NULL)
- PATCH /calendar/slots/{id}: refuses any edit on admin-locked
slots (423)
- POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
(423)
- PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
ongoing/paused/finished/aborted statuses (423 on other transitions)
5. **Maintenance-window guard on slot creation**
- POST /calendar/slots: rejects slot_type=system outright (only
materialiser may create system slots) and rejects any non-system
slot whose [scheduled_at, +duration] intersects the calling
user's schedule_type maintenance window (422). Handles 23->0 wrap
6. **Schema response**
- TimeSlotResponse / CalendarSlotItem now include is_admin_locked
and special_slot_id so clients can render the lock indicator and
trace back to the template
## Migration
Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
176 lines
5.5 KiB
Python
176 lines
5.5 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:
|
||
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,
|
||
)
|