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:
@@ -21,6 +21,11 @@ from app.core.config import get_db
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
|
||||
from app.models.models import User
|
||||
from app.models.agent import Agent, AgentStatus, ExhaustReason
|
||||
from app.models.schedule_type import ScheduleType
|
||||
from app.services.special_slot_materialiser import (
|
||||
materialise_special_slots_for_claw,
|
||||
materialise_special_slots_for_user,
|
||||
)
|
||||
from app.schemas.calendar import (
|
||||
AgentHeartbeatResponse,
|
||||
AgentStatusUpdateRequest,
|
||||
@@ -143,6 +148,8 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -168,6 +175,46 @@ def create_slot(
|
||||
"""
|
||||
target_date = payload.date or date_type.today()
|
||||
|
||||
# --- Maintenance-window guard ---
|
||||
# Non-`system` slots may not be placed inside the schedule_type's
|
||||
# 1-hour maintenance window. The window is admin-territory, reserved
|
||||
# for materialised special slots from `schedule_type_special_slots`.
|
||||
# `system` slot_type is itself reserved server-side (the materialiser
|
||||
# is the only legitimate caller) — refuse it here outright so the
|
||||
# public API cannot manufacture a fake admin-locked slot.
|
||||
if payload.slot_type == SlotType.SYSTEM:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"slot_type='system' is reserved for schedule_type special slots "
|
||||
"and cannot be created via this endpoint"
|
||||
),
|
||||
)
|
||||
_agent_for_user = (
|
||||
db.query(Agent).filter(Agent.user_id == current_user.id).first()
|
||||
)
|
||||
if _agent_for_user and _agent_for_user.schedule_type_id:
|
||||
st = (
|
||||
db.query(ScheduleType)
|
||||
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
|
||||
.first()
|
||||
)
|
||||
if st and _scheduled_inside_window(
|
||||
payload.scheduled_at,
|
||||
payload.estimated_duration,
|
||||
st.maintenance_from,
|
||||
st.maintenance_to,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
|
||||
f"intersects the maintenance window "
|
||||
f"{st.maintenance_from:02d}:00-{st.maintenance_to:02d}:00 UTC of "
|
||||
f"schedule_type '{st.name}' — that window is admin-reserved"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Overlap check (hard reject) ---
|
||||
conflicts = check_overlap_for_create(
|
||||
db,
|
||||
@@ -236,6 +283,8 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -289,7 +338,48 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
|
||||
return agent
|
||||
|
||||
|
||||
def _scheduled_inside_window(
|
||||
scheduled_at,
|
||||
estimated_duration_minutes: int,
|
||||
window_from_hour: int,
|
||||
window_to_hour: int,
|
||||
) -> bool:
|
||||
"""True if [scheduled_at, scheduled_at+duration] intersects [from:00, to:00].
|
||||
|
||||
Handles the 23→0 wrap case (window straddles UTC midnight).
|
||||
"""
|
||||
start_min = scheduled_at.hour * 60 + scheduled_at.minute
|
||||
end_min = start_min + max(estimated_duration_minutes, 1)
|
||||
win_start_min = window_from_hour * 60
|
||||
win_end_min = window_to_hour * 60
|
||||
if win_end_min > win_start_min:
|
||||
# normal same-day window
|
||||
return start_min < win_end_min and end_min > win_start_min
|
||||
# wrap-around: window = [from..24:00) ∪ [00:00..to)
|
||||
return (start_min < 24 * 60 and end_min > win_start_min) or end_min > win_end_min
|
||||
|
||||
|
||||
# Admin-locked special slots accept only these agent-driven status
|
||||
# transitions; movement / cancellation / arbitrary status edits are
|
||||
# rejected because the schedule_type owner is the source of truth.
|
||||
_ADMIN_LOCKED_ALLOWED_STATUSES = {
|
||||
SlotStatusEnum.ONGOING,
|
||||
SlotStatusEnum.PAUSED,
|
||||
SlotStatusEnum.FINISHED,
|
||||
SlotStatusEnum.ABORTED,
|
||||
}
|
||||
|
||||
|
||||
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (special slot); only "
|
||||
f"ongoing/paused/finished/aborted are allowed via agent-update"
|
||||
),
|
||||
)
|
||||
slot.status = payload.status.value
|
||||
if payload.started_at is not None:
|
||||
slot.started_at = payload.started_at
|
||||
@@ -377,6 +467,12 @@ def sync_schedules(
|
||||
"""
|
||||
today = date_type.today()
|
||||
|
||||
# Materialise today's special slots for every agent on this claw
|
||||
# before reading. This is idempotent — re-runs against an already-
|
||||
# materialised (agent, date, template) are no-ops. Plugin's runSync
|
||||
# picks them up like any other slot via the normal real_slots query.
|
||||
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
|
||||
|
||||
# Find all agents on this claw instance
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
@@ -514,6 +610,10 @@ def get_calendar_day(
|
||||
"""
|
||||
target_date = date or date_type.today()
|
||||
|
||||
# Materialise today's special slots for this user before reading,
|
||||
# so the day-view returns them alongside any user-created slots.
|
||||
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
|
||||
|
||||
# 1. Fetch real slots for the day
|
||||
real_slots = (
|
||||
db.query(TimeSlot)
|
||||
@@ -589,6 +689,20 @@ def edit_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
# Special slots materialised from a schedule_type template are
|
||||
# admin-owned; agents may complete/abort/pause/resume via the
|
||||
# plugin-facing agent-update endpoint but cannot edit time/type/
|
||||
# duration/event-data via this user-facing edit endpoint.
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can edit it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_edit_real_slot(db, slot)
|
||||
@@ -756,6 +870,16 @@ def cancel_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can cancel it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_cancel_real_slot(db, slot)
|
||||
|
||||
Reference in New Issue
Block a user