feat(calendar): maintenance window + schedule_type special slots
## 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>
This commit is contained in:
175
app/services/special_slot_materialiser.py
Normal file
175
app/services/special_slot_materialiser.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user