diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index 6a88ba7..2f2edcb 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -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) diff --git a/app/api/routers/schedule_type_special_slot.py b/app/api/routers/schedule_type_special_slot.py new file mode 100644 index 0000000..9e02328 --- /dev/null +++ b/app/api/routers/schedule_type_special_slot.py @@ -0,0 +1,223 @@ +"""Special-slot CRUD for a ScheduleType (admin-only). + +A "special slot" is a recurring slot template tied to a ScheduleType. +The system materialises one `time_slots` row per agent on that +schedule_type per date, scheduled inside the schedule_type's +maintenance window. Materialised rows are `is_admin_locked=true` — +agents can complete / abort / pause / resume them but cannot move +or cancel them. + +All endpoints require `schedule_type.manage` (admin auto-grants). +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.config import get_db +from app.api.deps import get_current_user_or_apikey +from app.models.models import User +from app.models.role_permission import Permission, RolePermission +from app.models.schedule_type import ScheduleType +from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot +from app.schemas.schedule_type_special_slot import ( + SpecialSlotCreate, + SpecialSlotUpdate, + SpecialSlotResponse, +) + + +router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"]) + + +# --------------------------------------------------------------------------- +# Permission helpers — mirror schedule_type.py's local helpers so this router +# doesn't have to depend on internal symbols of the other router. +# --------------------------------------------------------------------------- + +def _has_permission(db: Session, user: User, permission_name: str) -> bool: + if user.is_admin: + return True + if not user.role_id: + return False + return ( + db.query(RolePermission) + .join(Permission) + .filter( + RolePermission.role_id == user.role_id, + Permission.name == permission_name, + ) + .first() + is not None + ) + + +def _require_schedule_manage(db: Session, user: User) -> User: + if not _has_permission(db, user, "schedule_type.manage"): + raise HTTPException(403, "Permission denied: schedule_type.manage") + return user + + +def _require_schedule_read(db: Session, user: User) -> User: + if not _has_permission(db, user, "schedule_type.read"): + raise HTTPException(403, "Permission denied: schedule_type.read") + return user + + +def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType: + st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first() + if not st: + raise HTTPException(404, f"ScheduleType {schedule_type_id} not found") + return st + + +def _validate_fits_window( + minute_in_window: int, + estimated_duration: int, +) -> None: + """Reject special slots that wouldn't fit inside the 1-hour window.""" + if minute_in_window + estimated_duration > 60: + raise HTTPException( + 422, + ( + f"special slot does not fit in maintenance window: " + f"minute_in_window={minute_in_window} + " + f"estimated_duration={estimated_duration} > 60" + ), + ) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@router.get( + "/{schedule_type_id}/special-slots", + response_model=List[SpecialSlotResponse], + summary="List special slots for a schedule type", +) +def list_special_slots( + schedule_type_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_or_apikey), +): + _require_schedule_read(db, current_user) + _fetch_schedule_type(db, schedule_type_id) + return ( + db.query(ScheduleTypeSpecialSlot) + .filter(ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id) + .order_by( + ScheduleTypeSpecialSlot.minute_in_window.asc(), + ScheduleTypeSpecialSlot.id.asc(), + ) + .all() + ) + + +@router.post( + "/{schedule_type_id}/special-slots", + response_model=SpecialSlotResponse, + summary="Create a special slot for a schedule type (admin)", +) +def create_special_slot( + schedule_type_id: int, + payload: SpecialSlotCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_or_apikey), +): + _require_schedule_manage(db, current_user) + _fetch_schedule_type(db, schedule_type_id) + _validate_fits_window(payload.minute_in_window, payload.estimated_duration) + + dup = ( + db.query(ScheduleTypeSpecialSlot) + .filter( + ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id, + ScheduleTypeSpecialSlot.name == payload.name, + ) + .first() + ) + if dup: + raise HTTPException( + 409, + f"special slot '{payload.name}' already exists for schedule_type {schedule_type_id}", + ) + + slot = ScheduleTypeSpecialSlot( + schedule_type_id=schedule_type_id, + name=payload.name, + description=payload.description, + minute_in_window=payload.minute_in_window, + estimated_duration=payload.estimated_duration, + priority=payload.priority, + event_data=payload.event_data, + is_active=payload.is_active, + created_by_user_id=current_user.id, + ) + db.add(slot) + db.commit() + db.refresh(slot) + return slot + + +@router.patch( + "/{schedule_type_id}/special-slots/{slot_id}", + response_model=SpecialSlotResponse, + summary="Update a special slot (admin)", +) +def update_special_slot( + schedule_type_id: int, + slot_id: int, + payload: SpecialSlotUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_or_apikey), +): + _require_schedule_manage(db, current_user) + slot = ( + db.query(ScheduleTypeSpecialSlot) + .filter( + ScheduleTypeSpecialSlot.id == slot_id, + ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id, + ) + .first() + ) + if not slot: + raise HTTPException(404, "Special slot not found") + + update_fields = payload.model_dump(exclude_unset=True) + next_min = update_fields.get("minute_in_window", slot.minute_in_window) + next_dur = update_fields.get("estimated_duration", slot.estimated_duration) + _validate_fits_window(next_min, next_dur) + + for field, value in update_fields.items(): + setattr(slot, field, value) + db.commit() + db.refresh(slot) + return slot + + +@router.delete( + "/{schedule_type_id}/special-slots/{slot_id}", + summary="Delete a special slot (admin)", +) +def delete_special_slot( + schedule_type_id: int, + slot_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user_or_apikey), +): + _require_schedule_manage(db, current_user) + slot = ( + db.query(ScheduleTypeSpecialSlot) + .filter( + ScheduleTypeSpecialSlot.id == slot_id, + ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id, + ) + .first() + ) + if not slot: + raise HTTPException(404, "Special slot not found") + db.delete(slot) + db.commit() + return {"ok": True, "deleted": slot_id} diff --git a/app/main.py b/app/main.py index 45d9a7a..d668211 100644 --- a/app/main.py +++ b/app/main.py @@ -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:00–9: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() diff --git a/app/models/calendar.py b/app/models/calendar.py index ea13a60..d8ff963 100644 --- a/app/models/calendar.py +++ b/app/models/calendar.py @@ -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") # --------------------------------------------------------------------------- diff --git a/app/models/schedule_type.py b/app/models/schedule_type.py index f0a171b..5691676 100644 --- a/app/models/schedule_type.py +++ b/app/models/schedule_type.py @@ -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", + ) diff --git a/app/models/schedule_type_special_slot.py b/app/models/schedule_type_special_slot.py new file mode 100644 index 0000000..d3c8019 --- /dev/null +++ b/app/models/schedule_type_special_slot.py @@ -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": , "special_slot_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") diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py index f5891a3..088213c 100644 --- a/app/schemas/calendar.py +++ b/app/schemas/calendar.py @@ -144,6 +144,8 @@ class TimeSlotResponse(BaseModel): priority: int status: str plan_id: Optional[int] = None + is_admin_locked: bool = False + special_slot_id: Optional[int] = None created_at: Optional[dt_datetime] = None updated_at: Optional[dt_datetime] = None @@ -226,6 +228,8 @@ class CalendarSlotItem(BaseModel): priority: int status: str plan_id: Optional[int] = None + is_admin_locked: bool = False + special_slot_id: Optional[int] = None created_at: Optional[dt_datetime] = None updated_at: Optional[dt_datetime] = None diff --git a/app/schemas/schedule_type.py b/app/schemas/schedule_type.py index 3a51ecc..acdc695 100644 --- a/app/schemas/schedule_type.py +++ b/app/schemas/schedule_type.py @@ -1,15 +1,32 @@ """Schemas for ScheduleType CRUD.""" -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator from typing import Optional +def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None: + """Maintenance window must be exactly 1 hour (handles 23→0 wrap).""" + expected_to = (maintenance_from + 1) % 24 + if maintenance_to != expected_to: + raise ValueError( + f"maintenance window must be exactly 1 hour: " + f"expected maintenance_to={expected_to}, got {maintenance_to}" + ) + + class ScheduleTypeCreate(BaseModel): name: str = Field(..., min_length=1, max_length=64) work_from: int = Field(..., ge=0, le=23) work_to: int = Field(..., ge=0, le=23) entertainment_from: int = Field(..., ge=0, le=23) entertainment_to: int = Field(..., ge=0, le=23) + maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)") + maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24") + + @model_validator(mode="after") + def _check_maintenance(self): + _validate_maintenance_window(self.maintenance_from, self.maintenance_to) + return self class ScheduleTypeUpdate(BaseModel): @@ -18,6 +35,16 @@ class ScheduleTypeUpdate(BaseModel): work_to: Optional[int] = Field(None, ge=0, le=23) entertainment_from: Optional[int] = Field(None, ge=0, le=23) entertainment_to: Optional[int] = Field(None, ge=0, le=23) + maintenance_from: Optional[int] = Field(None, ge=0, le=23) + maintenance_to: Optional[int] = Field(None, ge=0, le=23) + + @model_validator(mode="after") + def _check_maintenance(self): + # Only validate when both fields are present together; partial- + # update validation against the merged row happens at apply time. + if self.maintenance_from is not None and self.maintenance_to is not None: + _validate_maintenance_window(self.maintenance_from, self.maintenance_to) + return self class ScheduleTypeResponse(BaseModel): @@ -27,6 +54,8 @@ class ScheduleTypeResponse(BaseModel): work_to: int entertainment_from: int entertainment_to: int + maintenance_from: int + maintenance_to: int class Config: from_attributes = True diff --git a/app/schemas/schedule_type_special_slot.py b/app/schemas/schedule_type_special_slot.py new file mode 100644 index 0000000..f3efcf0 --- /dev/null +++ b/app/schemas/schedule_type_special_slot.py @@ -0,0 +1,43 @@ +"""Schemas for ScheduleTypeSpecialSlot CRUD (admin-only).""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class SpecialSlotCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=512) + minute_in_window: int = Field(0, ge=0, le=59, description="Minute offset (0-59) inside the schedule_type maintenance window") + estimated_duration: int = Field(15, ge=1, le=60, description="Duration in minutes; must fit inside the 1-hour maintenance window") + priority: int = Field(50, ge=0, le=99) + event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data") + is_active: bool = True + + +class SpecialSlotUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=64) + description: Optional[str] = Field(None, max_length=512) + minute_in_window: Optional[int] = Field(None, ge=0, le=59) + estimated_duration: Optional[int] = Field(None, ge=1, le=60) + priority: Optional[int] = Field(None, ge=0, le=99) + event_data: Optional[dict[str, Any]] = None + is_active: Optional[bool] = None + + +class SpecialSlotResponse(BaseModel): + id: int + schedule_type_id: int + name: str + description: Optional[str] + minute_in_window: int + estimated_duration: int + priority: int + event_data: Optional[dict[str, Any]] + is_active: bool + created_by_user_id: int + created_at: datetime + + class Config: + from_attributes = True diff --git a/app/services/special_slot_materialiser.py b/app/services/special_slot_materialiser.py new file mode 100644 index 0000000..c9aa8fc --- /dev/null +++ b/app/services/special_slot_materialiser.py @@ -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=` 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, + )