9 Commits

Author SHA1 Message Date
e80ead528d fix(calendar): /agent/status idempotent + 409 on bad transition
Same-state transition was 500 (transition_to_busy asserts current=IDLE).
Now: short-circuit identical target → 200 no_change=true. Any other
state-machine violation surfaces as 409 with the actual error message
instead of generic 500.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:30 +01:00
f1aafb86df feat(calendar): GET /agent/status — read-only status query for plugin gate
Previously only POST /agent/status existed (for state transitions).
Fabric.OpenclawPlugin's triage on-call gate needs to check whether
the on-duty agent is currently on_call without flipping their state —
so the wake decision is read-only. GET returns {agent_id, status},
404 if unknown.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:34:10 +01:00
65905e4831 Merge pull request 'feat(schedule_type): minute-precision windows + variable maintenance length' (#21) from feat/schedule-type-minutes into main 2026-05-22 19:19:21 +00:00
345e0f3a04 feat(schedule_type): minute-precision windows + variable maintenance length
Lifts the two hard restrictions in PR #18:
  * window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439)
  * maintenance window was exactly 1 hour → now any duration in [1, 180] minutes
    ((maint_to - maint_from) mod 1440)

## Schema migration (additive)

`_migrate_schema()` detects legacy "hours" rows (any row where MAX of the
6 window columns is ≤ 23) and multiplies each column by 60 to convert
to minutes. Idempotent — post-conversion values are well above 23 so
the guard never fires twice.

## Touched surfaces

- `models/schedule_type.py` — column comments updated; new
  `compute_maintenance_duration()` helper (returns 1-1440 min, treats
  from==to as 1440 which is then rejected by validator)
- `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440;
  `_validate_maintenance_window` accepts 1-180min duration; response
  includes derived `maintenance_duration_minutes`
- `schemas/schedule_type_special_slot.py` — `minute_in_window` max
  59→179; `estimated_duration` max 60→180
- `routers/schedule_type.py` — PATCH re-validates merged maintenance
  pair (partial updates can put the row into an invalid combo the
  pydantic single-field validator can't catch); `_attach_derived`
  populates the new response field
- `routers/schedule_type_special_slot.py` — `_validate_fits_window`
  now takes the parent's maintenance duration instead of hard-coded 60
- `routers/calendar.py` — `_scheduled_inside_window` arg renamed
  hour→min; the maintenance-window guard error message formats
  HH:MM not HH:00
- `services/special_slot_materialiser.py` — materialised
  `scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)`
  with hour/minute split

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:18:21 +01:00
e5e81d418d Merge pull request 'feat(users): PATCH /users/{id}/bind-agent to backfill agents row' (#20) from feat/user-bind-agent into main 2026-05-22 18:58:25 +00:00
6400f7f612 feat(users): PATCH /users/{id}/bind-agent to backfill agents row
Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:58:06 +01:00
5b59806e38 Merge pull request 'fix(schedule-type): accept X-API-Key for CRUD' (#19) from feat/schedule-type-apikey-auth into main 2026-05-22 18:36:20 +00:00
23632aa073 fix(schedule-type): accept X-API-Key for CRUD
The /schedule-types/ router was the last surface still gated on
get_current_user (JWT-only). The companion special-slot router
(PR #18) used get_current_user_or_apikey, so the admin flow was:

  * create a schedule_type → DB direct insert (cli can't reach it)
  * add special slot via API → works

Swaps all 5 CRUD endpoints (list / create / patch / delete /
assign-agent) to get_current_user_or_apikey so the same hzhang
admin api_key that works for special-slot creation now works for
schedule_type creation too. /schedule-types/agent/me already uses
X-Agent-ID headers (not user auth), so no change there.

Existing JWT callers are unaffected — get_current_user_or_apikey
tries api_key first then falls back to JWT.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:35:56 +01:00
7017d3483e Merge pull request 'feat(calendar): maintenance window + schedule_type special slots' (#18) from feat/maintenance-window-and-special-slots into main 2026-05-22 18:19:06 +00:00
10 changed files with 296 additions and 113 deletions

View File

@@ -52,6 +52,7 @@ from app.schemas.calendar import (
) )
from app.services.agent_heartbeat import get_pending_slots_for_agent from app.services.agent_heartbeat import get_pending_slots_for_agent
from app.services.agent_status import ( from app.services.agent_status import (
AgentStatusError,
record_heartbeat, record_heartbeat,
transition_to_busy, transition_to_busy,
transition_to_idle, transition_to_idle,
@@ -205,12 +206,14 @@ def create_slot(
st.maintenance_from, st.maintenance_from,
st.maintenance_to, st.maintenance_to,
): ):
mf_h, mf_m = divmod(st.maintenance_from, 60)
mt_h, mt_m = divmod(st.maintenance_to, 60)
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail=( detail=(
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min " f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
f"intersects the maintenance window " f"intersects the maintenance window "
f"{st.maintenance_from:02d}:00-{st.maintenance_to:02d}:00 UTC of " f"{mf_h:02d}:{mf_m:02d}-{mt_h:02d}:{mt_m:02d} UTC of "
f"schedule_type '{st.name}' — that window is admin-reserved" f"schedule_type '{st.name}' — that window is admin-reserved"
), ),
) )
@@ -341,22 +344,21 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
def _scheduled_inside_window( def _scheduled_inside_window(
scheduled_at, scheduled_at,
estimated_duration_minutes: int, estimated_duration_minutes: int,
window_from_hour: int, window_from_min: int,
window_to_hour: int, window_to_min: int,
) -> bool: ) -> bool:
"""True if [scheduled_at, scheduled_at+duration] intersects [from:00, to:00]. """True if [scheduled_at, scheduled_at+duration] intersects [from, to).
Handles the 23→0 wrap case (window straddles UTC midnight). Window bounds are minutes-since-UTC-midnight (0-1439). Handles the
case where the window crosses UTC midnight (e.g. 23:30→01:00).
""" """
start_min = scheduled_at.hour * 60 + scheduled_at.minute start_min = scheduled_at.hour * 60 + scheduled_at.minute
end_min = start_min + max(estimated_duration_minutes, 1) end_min = start_min + max(estimated_duration_minutes, 1)
win_start_min = window_from_hour * 60 if window_to_min > window_from_min:
win_end_min = window_to_hour * 60
if win_end_min > win_start_min:
# normal same-day window # normal same-day window
return start_min < win_end_min and end_min > win_start_min return start_min < window_to_min and end_min > window_from_min
# wrap-around: window = [from..24:00) [00:00..to) # wrap-around: window = [from..1440) [0..to)
return (start_min < 24 * 60 and end_min > win_start_min) or end_min > win_end_min return (start_min < 1440 and end_min > window_from_min) or end_min > window_to_min
# Admin-locked special slots accept only these agent-driven status # Admin-locked special slots accept only these agent-driven status
@@ -560,6 +562,29 @@ def agent_update_virtual_slot(
return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[]) return TimeSlotEditResponse(slot=_slot_to_response(slot), warnings=[])
@router.get(
"/agent/status",
summary="Read an agent's current runtime status (no side effects)",
)
def get_agent_status(
agent_id: str = Query(..., description="Target agent_id"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return `{agent_id, status}` so callers (Fabric.OpenclawPlugin's
triage on-call gate, etc.) can decide whether the agent is currently
eligible without flipping their state.
No-op for unknown agents — returns 404 with `{detail: 'Agent not
found'}` so the caller can decide whether to fail-open or fail-closed.
"""
agent = _require_agent(db, agent_id, x_claw_identifier)
return {
"agent_id": agent.agent_id,
"status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status),
}
@router.post( @router.post(
"/agent/status", "/agent/status",
summary="Update agent runtime status from plugin", summary="Update agent runtime status from plugin",
@@ -570,6 +595,13 @@ def update_agent_status(
): ):
agent = _require_agent(db, payload.agent_id, payload.claw_identifier) agent = _require_agent(db, payload.agent_id, payload.claw_identifier)
target = (payload.status or '').lower().strip() target = (payload.status or '').lower().strip()
# Idempotent same-state transition: a 'busy → busy' request is a
# no-op rather than a 500. Lets plugin status gates / cli `--set`
# be safe to fire-and-forget without first reading current state.
current = agent.status.value if hasattr(agent.status, 'value') else str(agent.status)
if current == target:
return {"ok": True, "agent_id": agent.agent_id, "status": current, "no_change": True}
try:
if target == AgentStatus.IDLE.value: if target == AgentStatus.IDLE.value:
transition_to_idle(db, agent) transition_to_idle(db, agent)
elif target == AgentStatus.BUSY.value: elif target == AgentStatus.BUSY.value:
@@ -583,6 +615,11 @@ def update_agent_status(
transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at) transition_to_exhausted(db, agent, reason=reason, recovery_at=payload.recovery_at)
else: else:
raise HTTPException(status_code=400, detail="Unsupported agent status") raise HTTPException(status_code=400, detail="Unsupported agent status")
except AgentStatusError as e:
# State-machine violation (e.g. busy → busy via wrong precondition)
# → 409 with the rejected transition explained, instead of a 500.
db.rollback()
raise HTTPException(status_code=409, detail=str(e))
db.commit() db.commit()
return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)} return {"ok": True, "agent_id": agent.agent_id, "status": agent.status.value if hasattr(agent.status, 'value') else str(agent.status)}

View File

@@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
from typing import List from typing import List
from app.core.config import get_db from app.core.config import get_db
from app.api.deps import get_current_user from app.api.deps import get_current_user_or_apikey
from app.models.models import User from app.models.models import User
from app.models.agent import Agent from app.models.agent import Agent
from app.models.schedule_type import ScheduleType from app.models.schedule_type import ScheduleType
@@ -57,6 +57,18 @@ def _require_schedule_manage(db: Session, user: User) -> User:
return user return user
def _attach_derived(st: ScheduleType) -> ScheduleType:
"""Attach derived fields (maintenance_duration_minutes) so the
pydantic ScheduleTypeResponse picks them up via from_attributes.
Pydantic with from_attributes reads attributes off the ORM object;
setting a transient attr here avoids having to convert through dict.
"""
if st is not None:
st.maintenance_duration_minutes = st.compute_maintenance_duration()
return st
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Schedule Type CRUD # Schedule Type CRUD
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -68,10 +80,10 @@ def _require_schedule_manage(db: Session, user: User) -> User:
) )
def list_schedule_types( def list_schedule_types(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_read(db, current_user) _require_schedule_read(db, current_user)
return db.query(ScheduleType).all() return [_attach_derived(st) for st in db.query(ScheduleType).all()]
@router.post( @router.post(
@@ -82,7 +94,7 @@ def list_schedule_types(
def create_schedule_type( def create_schedule_type(
payload: ScheduleTypeCreate, payload: ScheduleTypeCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -96,11 +108,13 @@ def create_schedule_type(
work_to=payload.work_to, work_to=payload.work_to,
entertainment_from=payload.entertainment_from, entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to, entertainment_to=payload.entertainment_to,
maintenance_from=payload.maintenance_from,
maintenance_to=payload.maintenance_to,
) )
db.add(st) db.add(st)
db.commit() db.commit()
db.refresh(st) db.refresh(st)
return st return _attach_derived(st)
@router.patch( @router.patch(
@@ -112,7 +126,7 @@ def update_schedule_type(
schedule_type_id: int, schedule_type_id: int,
payload: ScheduleTypeUpdate, payload: ScheduleTypeUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -120,12 +134,23 @@ def update_schedule_type(
if not st: if not st:
raise HTTPException(404, "Schedule type not found") raise HTTPException(404, "Schedule type not found")
for field, value in payload.model_dump(exclude_unset=True).items(): update_fields = payload.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(st, field, value) setattr(st, field, value)
# Re-validate maintenance after merge (partial updates can put the row
# into an invalid window combo that the pydantic schema couldn't catch
# because it only saw one field).
from app.schemas.schedule_type import _validate_maintenance_window
try:
_validate_maintenance_window(st.maintenance_from, st.maintenance_to)
except ValueError as e:
db.rollback()
raise HTTPException(422, str(e))
db.commit() db.commit()
db.refresh(st) db.refresh(st)
return st return _attach_derived(st)
@router.delete( @router.delete(
@@ -135,7 +160,7 @@ def update_schedule_type(
def delete_schedule_type( def delete_schedule_type(
schedule_type_id: int, schedule_type_id: int,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
@@ -181,7 +206,8 @@ def get_my_schedule_type(
if not agent.schedule_type_id: if not agent.schedule_type_id:
return None return None
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first() st = db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
return _attach_derived(st) if st else None
@router.put( @router.put(
@@ -192,7 +218,7 @@ def assign_schedule_type(
agent_id: str, agent_id: str,
payload: AgentScheduleTypeAssign, payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)

View File

@@ -75,15 +75,17 @@ def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
def _validate_fits_window( def _validate_fits_window(
minute_in_window: int, minute_in_window: int,
estimated_duration: int, estimated_duration: int,
maintenance_duration_minutes: int,
) -> None: ) -> None:
"""Reject special slots that wouldn't fit inside the 1-hour window.""" """Reject special slots that wouldn't fit inside the parent's maintenance window."""
if minute_in_window + estimated_duration > 60: if minute_in_window + estimated_duration > maintenance_duration_minutes:
raise HTTPException( raise HTTPException(
422, 422,
( (
f"special slot does not fit in maintenance window: " f"special slot does not fit in maintenance window: "
f"minute_in_window={minute_in_window} + " f"minute_in_window={minute_in_window} + "
f"estimated_duration={estimated_duration} > 60" f"estimated_duration={estimated_duration} > "
f"maintenance window {maintenance_duration_minutes}min"
), ),
) )
@@ -127,8 +129,8 @@ def create_special_slot(
current_user: User = Depends(get_current_user_or_apikey), current_user: User = Depends(get_current_user_or_apikey),
): ):
_require_schedule_manage(db, current_user) _require_schedule_manage(db, current_user)
_fetch_schedule_type(db, schedule_type_id) st = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(payload.minute_in_window, payload.estimated_duration) _validate_fits_window(payload.minute_in_window, payload.estimated_duration, st.compute_maintenance_duration())
dup = ( dup = (
db.query(ScheduleTypeSpecialSlot) db.query(ScheduleTypeSpecialSlot)
@@ -188,7 +190,8 @@ def update_special_slot(
update_fields = payload.model_dump(exclude_unset=True) update_fields = payload.model_dump(exclude_unset=True)
next_min = update_fields.get("minute_in_window", slot.minute_in_window) next_min = update_fields.get("minute_in_window", slot.minute_in_window)
next_dur = update_fields.get("estimated_duration", slot.estimated_duration) next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
_validate_fits_window(next_min, next_dur) parent = _fetch_schedule_type(db, schedule_type_id)
_validate_fits_window(next_min, next_dur, parent.compute_maintenance_duration())
for field, value in update_fields.items(): for field, value in update_fields.items():
setattr(slot, field, value) setattr(slot, field, value)

View File

@@ -221,6 +221,71 @@ def update_user(
return _user_response(user) return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} _BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}

View File

@@ -400,9 +400,9 @@ def _migrate_schema():
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL")) db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
# --- schedule_types: add maintenance_from / maintenance_to --- # --- schedule_types: add maintenance_from / maintenance_to ---
# Default 8:009:00 UTC for existing rows; the 1-hour-window # Default 8:009:00 UTC for existing rows; the maintenance
# invariant is enforced at the schema level for any NEW rows by # duration invariant (1-180min) is enforced at the schema
# the pydantic ScheduleTypeCreate validator. # level for any NEW rows by ScheduleTypeCreate validator.
if _has_table(db, "schedule_types"): if _has_table(db, "schedule_types"):
if not _has_column(db, "schedule_types", "maintenance_from"): if not _has_column(db, "schedule_types", "maintenance_from"):
db.execute(text( db.execute(text(
@@ -413,6 +413,29 @@ def _migrate_schema():
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9" "ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
)) ))
# --- minutes-since-midnight migration (PR #21+) ---
# The 6 schedule_type window columns used to hold *hours*
# (0-23). PR #21 changed semantics to *minutes since UTC
# midnight* (0-1439). Detect the legacy regime by checking
# if ANY row has all 6 values ≤ 23 — if so, multiply each
# by 60 to convert. Idempotent: post-conversion values are
# all ≥ 0 and usually well above 23, so guard never fires
# twice.
row = db.execute(text(
"SELECT MAX(GREATEST(work_from, work_to, entertainment_from, entertainment_to, maintenance_from, maintenance_to)) AS m "
"FROM schedule_types"
)).fetchone()
if row is not None and row.m is not None and row.m <= 23:
db.execute(text(
"UPDATE schedule_types SET "
" work_from = work_from * 60, "
" work_to = work_to * 60, "
" entertainment_from = entertainment_from * 60, "
" entertainment_to = entertainment_to * 60, "
" maintenance_from = maintenance_from * 60, "
" maintenance_to = maintenance_to * 60"
))
# --- time_slots: admin-locked + special_slot pointer --- # --- time_slots: admin-locked + special_slot pointer ---
if _has_table(db, "time_slots"): if _has_table(db, "time_slots"):
if not _has_column(db, "time_slots", "is_admin_locked"): if not _has_column(db, "time_slots", "is_admin_locked"):

View File

@@ -1,9 +1,17 @@
"""ScheduleType model — defines work/entertainment/maintenance time periods. """ScheduleType model — defines work/entertainment/maintenance time periods.
Each ScheduleType defines the daily work, entertainment, and maintenance Each ScheduleType defines the daily work, entertainment, and maintenance
windows. Agents reference a schedule_type to know when they should be windows for agents who reference this type. All bounds are stored as
working, when they can engage in entertainment, and when the system **minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
requires them to surrender control for admin-scheduled special slots. sub-hour boundaries are exact.
Maintenance window length is variable (1-180 minutes) and admin-owned;
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
maintenance window was hard-fixed at exactly 1 hour. The additive
migration in `_migrate_schema()` multiplies legacy values by 60 so
existing rows convert transparently.
""" """
from sqlalchemy import Column, Integer, String, DateTime from sqlalchemy import Column, Integer, String, DateTime
@@ -26,52 +34,26 @@ class ScheduleType(Base):
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')", comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
) )
work_from = Column( # Minutes since UTC midnight, 0-1439 inclusive.
Integer, work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
nullable=False, work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
comment="Work period start hour (0-23, UTC)",
)
work_to = Column( entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
Integer, entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column( # Maintenance window — admin-owned, variable length (1-180 min).
Integer, # Default 8:009:00 UTC = 480540 minutes for existing rows.
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
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:009:00 UTC so deployments stay sane until an operator
# picks proper hours per schedule_type.
# -----------------------------------------------------------------
maintenance_from = Column( maintenance_from = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="8", server_default="480",
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.", comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
) )
maintenance_to = Column( maintenance_to = Column(
Integer, Integer,
nullable=False, nullable=False,
server_default="9", server_default="540",
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.", comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
) )
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -83,3 +65,19 @@ class ScheduleType(Base):
back_populates="schedule_type", back_populates="schedule_type",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# ---------------------------------------------------------------
# Convenience methods used by the API layer + materialiser.
# ---------------------------------------------------------------
def compute_maintenance_duration(self) -> int:
"""Maintenance window length in minutes (handles 23→0 wrap)."""
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
if win_to > win_from:
return start_min < win_to and end_min > win_from
# wrap window crosses midnight: [win_from..1440) [0..win_to)
return start_min < win_to or end_min > win_from

View File

@@ -1,27 +1,44 @@
"""Schemas for ScheduleType CRUD.""" """Schemas for ScheduleType CRUD.
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
A maintenance window of variable length is allowed (1-180 minutes,
handles 23→0 wrap).
"""
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from typing import Optional from typing import Optional
def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None: _MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
"""Maintenance window must be exactly 1 hour (handles 23→0 wrap)."""
expected_to = (maintenance_from + 1) % 24
if maintenance_to != expected_to: def _maintenance_duration(maint_from: int, maint_to: int) -> int:
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
dur = _maintenance_duration(maint_from, maint_to)
if dur < 1 or dur > 180:
raise ValueError( raise ValueError(
f"maintenance window must be exactly 1 hour: " f"maintenance window duration must be in [1, 180] minutes; "
f"expected maintenance_to={expected_to}, got {maintenance_to}" f"got {dur} (from={maint_from}, to={maint_to})"
) )
def _validate_minute_field(name: str, value: int) -> None:
if value < 0 or value >= _MAX_MIN:
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
class ScheduleTypeCreate(BaseModel): class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, le=23) work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
work_to: int = Field(..., ge=0, le=23) work_to: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_from: int = Field(..., ge=0, le=23) entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_to: int = Field(..., ge=0, le=23) entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)") maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24") maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -31,12 +48,12 @@ class ScheduleTypeCreate(BaseModel):
class ScheduleTypeUpdate(BaseModel): class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, le=23) work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
work_to: Optional[int] = Field(None, ge=0, le=23) work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_from: Optional[int] = Field(None, ge=0, le=23) entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_to: Optional[int] = Field(None, ge=0, le=23) entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_from: Optional[int] = Field(None, ge=0, le=23) maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_to: Optional[int] = Field(None, ge=0, le=23) maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
@model_validator(mode="after") @model_validator(mode="after")
def _check_maintenance(self): def _check_maintenance(self):
@@ -56,6 +73,7 @@ class ScheduleTypeResponse(BaseModel):
entertainment_to: int entertainment_to: int
maintenance_from: int maintenance_from: int
maintenance_to: int maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -9,8 +9,8 @@ from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel): class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64) name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) 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") minute_in_window: int = Field(0, ge=0, le=179, description="Minute offset (0-179) 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") estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
priority: int = Field(50, ge=0, le=99) 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") event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True is_active: bool = True
@@ -19,8 +19,8 @@ class SpecialSlotCreate(BaseModel):
class SpecialSlotUpdate(BaseModel): class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64) name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512) description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=59) minute_in_window: Optional[int] = Field(None, ge=0, le=179)
estimated_duration: Optional[int] = Field(None, ge=1, le=60) estimated_duration: Optional[int] = Field(None, ge=1, le=180)
priority: Optional[int] = Field(None, ge=0, le=99) priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime, time from datetime import datetime, time
from enum import Enum from enum import Enum
@@ -186,6 +186,19 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool

View File

@@ -145,11 +145,11 @@ def _build_time_slot_from_template(
schedule_type: ScheduleType, schedule_type: ScheduleType,
template: ScheduleTypeSpecialSlot, template: ScheduleTypeSpecialSlot,
) -> TimeSlot: ) -> TimeSlot:
scheduled_at = time_type( # schedule_type.maintenance_from is minutes-since-UTC-midnight; the
hour=schedule_type.maintenance_from, # template's minute_in_window is an offset inside that window. Combined
minute=template.minute_in_window, # offset must fit in [0, 1440) and produce a wall-clock time_type.
second=0, total_min = (schedule_type.maintenance_from + template.minute_in_window) % 1440
) scheduled_at = time_type(hour=total_min // 60, minute=total_min % 60, second=0)
# Merge admin-supplied event_data with bookkeeping pointers so the # Merge admin-supplied event_data with bookkeeping pointers so the
# agent (and ARD) can identify the template at wake time. # agent (and ARD) can identify the template at wake time.
merged_event_data = dict(template.event_data or {}) merged_event_data = dict(template.event_data or {})