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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""ScheduleType model — defines work/entertainment time periods.
|
||||
"""ScheduleType model — defines work/entertainment/maintenance time periods.
|
||||
|
||||
Each ScheduleType defines the daily work and entertainment windows.
|
||||
Agents reference a schedule_type to know when they should be working
|
||||
vs when they can engage in entertainment activities.
|
||||
Each ScheduleType defines the daily work, entertainment, and maintenance
|
||||
windows. Agents reference a schedule_type to know when they should be
|
||||
working, when they can engage in entertainment, and when the system
|
||||
requires them to surrender control for admin-scheduled special slots.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class ScheduleType(Base):
|
||||
"""Work/entertainment period definition."""
|
||||
"""Work/entertainment/maintenance period definition."""
|
||||
|
||||
__tablename__ = "schedule_types"
|
||||
|
||||
@@ -48,5 +50,36 @@ class ScheduleType(Base):
|
||||
comment="Entertainment period end hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Maintenance window — every agent on this schedule_type must
|
||||
# surrender work/entertainment slots during this hour. Admin-created
|
||||
# special slots tied to this schedule_type can only be scheduled
|
||||
# inside this window. The window is always exactly 1 hour.
|
||||
#
|
||||
# Default (when columns are added via additive migration to existing
|
||||
# rows) is 8:00–9:00 UTC so deployments stay sane until an operator
|
||||
# picks proper hours per schedule_type.
|
||||
# -----------------------------------------------------------------
|
||||
maintenance_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="8",
|
||||
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.",
|
||||
)
|
||||
|
||||
maintenance_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="9",
|
||||
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.",
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# relationship ---------------------------------------------------
|
||||
special_slots = relationship(
|
||||
"ScheduleTypeSpecialSlot",
|
||||
back_populates="schedule_type",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
116
app/models/schedule_type_special_slot.py
Normal file
116
app/models/schedule_type_special_slot.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""ScheduleTypeSpecialSlot — admin-managed slot template tied to a ScheduleType.
|
||||
|
||||
A "special slot" is a recurring slot template that the system materializes
|
||||
into every matching agent's `time_slots` row each day. It exists for tasks
|
||||
that admin wants to enforce across an entire schedule type cohort, e.g.:
|
||||
|
||||
* `plan-schedule` — daily planning slot all agents on this type must run
|
||||
* `secret-rotation-window` — security maintenance
|
||||
* `policy-update` — read updated agent policies
|
||||
|
||||
Rules:
|
||||
* Only admins (`schedule_type.manage` permission) may create / edit /
|
||||
delete special slots.
|
||||
* The slot's `minute_in_window` offset must place it inside the parent
|
||||
schedule_type's maintenance window (`maintenance_from..maintenance_from+59`).
|
||||
* Materialised `time_slots` rows from a special slot carry
|
||||
`is_admin_locked=true` so the agent-side `PATCH .../agent-update`
|
||||
refuses status/time edits other than complete/abort/pause/resume.
|
||||
* Materialisation produces one `time_slots` row per agent using this
|
||||
schedule_type per date, with `slot_type=system`, `event_type=system_event`,
|
||||
`event_data={"special_slot_id": <id>, "special_slot_name": "<name>",
|
||||
"source": "schedule_type_special_slot", ...admin-supplied...}`.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, JSON, DateTime, Boolean, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.config import Base
|
||||
|
||||
|
||||
class ScheduleTypeSpecialSlot(Base):
|
||||
"""Admin-managed daily slot template attached to a ScheduleType."""
|
||||
|
||||
__tablename__ = "schedule_type_special_slots"
|
||||
__table_args__ = (
|
||||
# One slot template per (schedule_type, name) so admin can use the
|
||||
# `name` field as a stable, human-readable identifier for the cohort.
|
||||
UniqueConstraint("schedule_type_id", "name", name="uq_special_slot_type_name"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
schedule_type_id = Column(
|
||||
Integer,
|
||||
ForeignKey("schedule_types.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
name = Column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
comment="Short identifier, e.g. 'plan-schedule', 'secret-rotation'",
|
||||
)
|
||||
|
||||
description = Column(
|
||||
String(512),
|
||||
nullable=True,
|
||||
comment="Human-readable note on what this slot is for",
|
||||
)
|
||||
|
||||
minute_in_window = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment=(
|
||||
"Minute offset (0-59) inside the schedule_type maintenance window. "
|
||||
"The materialised time_slot's scheduled_at becomes "
|
||||
"maintenance_from:minute_in_window:00 UTC."
|
||||
),
|
||||
)
|
||||
|
||||
estimated_duration = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="15",
|
||||
comment="Duration in minutes. Must fit inside the maintenance window.",
|
||||
)
|
||||
|
||||
priority = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="50",
|
||||
comment="Wake priority — higher value wakes first if multiple slots are due.",
|
||||
)
|
||||
|
||||
event_data = Column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment=(
|
||||
"Admin-supplied JSON payload that gets merged into every "
|
||||
"materialised slot's event_data. Use this to pass a workflow "
|
||||
"tag, suggested_workload, or any other context the agent "
|
||||
"should see in its wakeup message."
|
||||
),
|
||||
)
|
||||
|
||||
is_active = Column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="Soft-disable without deleting; inactive templates are skipped during materialisation.",
|
||||
)
|
||||
|
||||
created_by_user_id = Column(
|
||||
Integer,
|
||||
ForeignKey("users.id"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# relationship ---------------------------------------------------
|
||||
schedule_type = relationship("ScheduleType", back_populates="special_slots")
|
||||
Reference in New Issue
Block a user