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:
@@ -178,11 +178,37 @@ class TimeSlot(Base):
|
||||
comment="Source plan if materialized from a SchedulePlan; set NULL on edit/cancel",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Admin-locked slots are materialised from a ScheduleTypeSpecialSlot
|
||||
# template. The agent can complete / abort / pause / resume them but
|
||||
# cannot edit their time, type, duration, or cancel them outright —
|
||||
# the slot exists because admin decided every agent on the parent
|
||||
# schedule_type should run it. See `_apply_agent_slot_update` for
|
||||
# the enforcement.
|
||||
# -----------------------------------------------------------------
|
||||
is_admin_locked = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="True for slots materialised from a schedule_type special slot template.",
|
||||
)
|
||||
|
||||
# Pointer back to the template that materialised this slot. NULL for
|
||||
# all user-created or plan-generated slots. Lets us cascade updates
|
||||
# and surface 'why is this on my calendar' to the agent.
|
||||
special_slot_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_type_special_slots.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# relationship ----------------------------------------------------------
|
||||
plan = relationship("SchedulePlan", back_populates="materialized_slots")
|
||||
special_slot = relationship("ScheduleTypeSpecialSlot")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user