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:
hanghang zhang
2026-05-22 19:18:36 +01:00
parent 4675ab7201
commit 2cbf6445eb
10 changed files with 816 additions and 7 deletions

View File

@@ -78,6 +78,7 @@ from app.api.routers.milestone_actions import router as milestone_actions_router
from app.api.routers.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_router
from app.api.routers.schedule_type import router as schedule_type_router
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
from app.api.routers.calendar import router as calendar_router
from app.api.routers.oidc import router as oidc_router
@@ -98,6 +99,7 @@ app.include_router(milestone_actions_router)
app.include_router(meetings_router)
app.include_router(essentials_router)
app.include_router(schedule_type_router)
app.include_router(schedule_type_special_slot_router)
app.include_router(calendar_router)
@@ -397,6 +399,40 @@ def _migrate_schema():
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the 1-hour-window
# invariant is enforced at the schema level for any NEW rows by
# the pydantic ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
))
if not _has_column(db, "schedule_types", "maintenance_to"):
db.execute(text(
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
))
# --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
))
if not _has_column(db, "time_slots", "special_slot_id"):
db.execute(text(
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
))
# Index for the materialiser's idempotency lookup
db.execute(text(
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
))
# --- schedule_type_special_slots: create-table is handled by
# Base.metadata.create_all on first boot; no migration needed here
# because there is no legacy table to evolve. Future schema bumps
# to that table go in this block.
db.commit()
except Exception as e:
db.rollback()
@@ -431,7 +467,7 @@ def _sync_default_user_roles(db):
@app.on_event("startup")
def startup():
from app.core.config import Base, engine, SessionLocal
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, oidc_settings
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings
Base.metadata.create_all(bind=engine)
_migrate_schema()