Compare commits

..

2 Commits

Author SHA1 Message Date
zhi
d52861fd9c feat: schedule type system for work/entertainment periods
- New model: ScheduleType (name, work_from/to, entertainment_from/to)
- Agent.schedule_type_id FK to schedule_types
- CRUD API: GET/POST/PATCH/DELETE /schedule-types/
- Agent assignment: PUT /schedule-types/agent/{agent_id}/assign
- Agent self-query: GET /schedule-types/agent/me
- Permissions: schedule_type.read, schedule_type.manage
- Migration: adds schedule_type_id column to agents table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 09:20:51 +00:00
zhi
3aa6dd2d6e feat: add /calendar/sync endpoint for multi-agent schedule sync
Returns today's slots for all agents on a claw instance, keyed by
agent_id. Used by HF Plugin to maintain a local schedule cache
instead of per-agent heartbeat.

Also records heartbeat for all agents on the instance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 09:30:57 +00:00
9 changed files with 391 additions and 172 deletions

View File

@@ -1,46 +1,25 @@
# Stage 1: build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Pre-download wheels to avoid recompiling bcrypt from source
RUN pip install --no-cache-dir --prefix=/install \
'bcrypt==4.0.1' \
'cffi>=2.0' \
'pycparser>=2.0'
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: slim runtime
FROM python:3.11-slim FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install runtime dependencies only (no build tools) # Install system dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
default-libmysqlclient-dev \ build-essential \
curl \ curl \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy installed packages from builder # Install Python dependencies
COPY --from=builder /install /usr/local COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code
COPY app/ ./app/ COPY . .
COPY requirements.txt ./
# Make entrypoint
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh RUN chmod +x entrypoint.sh
# Expose port
EXPOSE 8000 EXPOSE 8000
# Wait for wizard config, then start uvicorn
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -361,6 +361,62 @@ def agent_heartbeat(
) )
@router.get(
"/sync",
summary="Sync today's schedules for all agents on a claw instance",
)
def sync_schedules(
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
"""Return today's slots for all agents belonging to the given claw instance.
Used by the HF OpenClaw plugin to maintain a local schedule cache.
Returns a dict of { agent_id: [slots] } for all agents with matching
claw_identifier.
"""
today = date_type.today()
# Find all agents on this claw instance
agents = (
db.query(Agent)
.filter(Agent.claw_identifier == x_claw_identifier)
.all()
)
schedules: dict[str, list[dict]] = {}
for agent in agents:
# Get real slots for today
real_slots = (
db.query(TimeSlot)
.filter(
TimeSlot.user_id == agent.user_id,
TimeSlot.date == today,
TimeSlot.status.notin_(list(_INACTIVE_STATUSES)),
)
.all()
)
items = [_real_slot_to_item(s).model_dump(mode="json") for s in real_slots]
# Get virtual plan slots
virtual_slots = get_virtual_slots_for_date(db, agent.user_id, today)
for vs in virtual_slots:
items.append(_virtual_slot_to_item(vs).model_dump(mode="json"))
schedules[agent.agent_id] = items
# Record heartbeat for liveness
for agent in agents:
record_heartbeat(db, agent)
db.commit()
return {
"schedules": schedules,
"date": today.isoformat(),
"agent_count": len(agents),
}
@router.patch( @router.patch(
"/slots/{slot_id}/agent-update", "/slots/{slot_id}/agent-update",
response_model=TimeSlotEditResponse, response_model=TimeSlotEditResponse,

View File

@@ -0,0 +1,209 @@
"""ScheduleType API router.
CRUD for schedule types (work/entertainment time periods)
and agent schedule type assignment.
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user
from app.models.models import User
from app.models.agent import Agent
from app.models.schedule_type import ScheduleType
from app.models.role_permission import Permission, RolePermission
from app.schemas.schedule_type import (
ScheduleTypeCreate,
ScheduleTypeUpdate,
ScheduleTypeResponse,
AgentScheduleTypeAssign,
)
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
# ---------------------------------------------------------------------------
# Permission helpers
# ---------------------------------------------------------------------------
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_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 _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
# ---------------------------------------------------------------------------
# Schedule Type CRUD
# ---------------------------------------------------------------------------
@router.get(
"/",
response_model=List[ScheduleTypeResponse],
summary="List all schedule types",
)
def list_schedule_types(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_schedule_read(db, current_user)
return db.query(ScheduleType).all()
@router.post(
"/",
response_model=ScheduleTypeResponse,
summary="Create a schedule type",
)
def create_schedule_type(
payload: ScheduleTypeCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
existing = db.query(ScheduleType).filter(ScheduleType.name == payload.name).first()
if existing:
raise HTTPException(409, f"Schedule type '{payload.name}' already exists")
st = ScheduleType(
name=payload.name,
work_from=payload.work_from,
work_to=payload.work_to,
entertainment_from=payload.entertainment_from,
entertainment_to=payload.entertainment_to,
)
db.add(st)
db.commit()
db.refresh(st)
return st
@router.patch(
"/{schedule_type_id}",
response_model=ScheduleTypeResponse,
summary="Update a schedule type",
)
def update_schedule_type(
schedule_type_id: int,
payload: ScheduleTypeUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(st, field, value)
db.commit()
db.refresh(st)
return st
@router.delete(
"/{schedule_type_id}",
summary="Delete a schedule type",
)
def delete_schedule_type(
schedule_type_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
if not st:
raise HTTPException(404, "Schedule type not found")
# Check if any agents are using this schedule type
agents_using = db.query(Agent).filter(Agent.schedule_type_id == schedule_type_id).count()
if agents_using > 0:
raise HTTPException(
409,
f"Cannot delete: {agents_using} agent(s) are assigned to this schedule type",
)
db.delete(st)
db.commit()
return {"ok": True, "deleted": schedule_type_id}
# ---------------------------------------------------------------------------
# Agent schedule type assignment (agent-facing, uses X-Agent-ID header)
# ---------------------------------------------------------------------------
@router.get(
"/agent/me",
response_model=ScheduleTypeResponse | None,
summary="Get my schedule type",
)
def get_my_schedule_type(
x_agent_id: str = Header(..., alias="X-Agent-ID"),
x_claw_identifier: str = Header(..., alias="X-Claw-Identifier"),
db: Session = Depends(get_db),
):
agent = (
db.query(Agent)
.filter(Agent.agent_id == x_agent_id, Agent.claw_identifier == x_claw_identifier)
.first()
)
if not agent:
raise HTTPException(404, "Agent not found")
if not agent.schedule_type_id:
return None
return db.query(ScheduleType).filter(ScheduleType.id == agent.schedule_type_id).first()
@router.put(
"/agent/{agent_id}/assign",
summary="Assign a schedule type to an agent",
)
def assign_schedule_type(
agent_id: str,
payload: AgentScheduleTypeAssign,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_schedule_manage(db, current_user)
agent = db.query(Agent).filter(Agent.agent_id == agent_id).first()
if not agent:
raise HTTPException(404, f"Agent '{agent_id}' not found")
st = db.query(ScheduleType).filter(ScheduleType.name == payload.schedule_type_name).first()
if not st:
raise HTTPException(404, f"Schedule type '{payload.schedule_type_name}' not found")
agent.schedule_type_id = st.id
db.commit()
return {"ok": True, "agent_id": agent_id, "schedule_type": st.name}

View File

@@ -9,7 +9,6 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash from app.api.deps import get_current_user, get_current_user_or_apikey, get_password_hash
from app.core.config import get_db from app.core.config import get_db
from app.init_wizard import DELETED_USER_USERNAME
from app.models import models from app.models import models
from app.models.agent import Agent from app.models.agent import Agent
from app.models.role_permission import Permission, Role, RolePermission from app.models.role_permission import Permission, Role, RolePermission
@@ -213,86 +212,6 @@ def update_user(
return _user_response(user) return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}
def _reassign_user_references(db: Session, old_id: int, new_id: int) -> None:
"""Reassign all foreign key references from old_id to new_id, then delete
records that would be meaningless under deleted-user (api_keys, notifications,
project memberships)."""
from app.models.apikey import APIKey
from app.models.notification import Notification
from app.models.activity import ActivityLog as Activity
from app.models.worklog import WorkLog as WorkLogModel
from app.models.meeting import Meeting, MeetingParticipant
from app.models.task import Task
from app.models.support import Support
from app.models.proposal import Proposal
from app.models.milestone import Milestone
from app.models.calendar import TimeSlot, SchedulePlan
from app.models.minimum_workload import MinimumWorkload
from app.models.essential import Essential
# Delete records that are meaningless without the real user
db.query(APIKey).filter(APIKey.user_id == old_id).delete()
db.query(Notification).filter(Notification.user_id == old_id).delete()
db.query(models.ProjectMember).filter(models.ProjectMember.user_id == old_id).delete()
# Reassign ownership/authorship references
db.query(models.Project).filter(models.Project.owner_id == old_id).update(
{"owner_id": new_id})
db.query(models.Comment).filter(models.Comment.author_id == old_id).update(
{"author_id": new_id})
db.query(Activity).filter(Activity.user_id == old_id).update(
{"user_id": new_id})
db.query(WorkLogModel).filter(WorkLogModel.user_id == old_id).update(
{"user_id": new_id})
# Tasks
db.query(Task).filter(Task.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Task).filter(Task.assignee_id == old_id).update(
{"assignee_id": new_id})
db.query(Task).filter(Task.created_by_id == old_id).update(
{"created_by_id": new_id})
# Meetings
db.query(Meeting).filter(Meeting.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(MeetingParticipant).filter(MeetingParticipant.user_id == old_id).update(
{"user_id": new_id})
# Support
db.query(Support).filter(Support.reporter_id == old_id).update(
{"reporter_id": new_id})
db.query(Support).filter(Support.assignee_id == old_id).update(
{"assignee_id": new_id})
# Proposals
db.query(Proposal).filter(Proposal.created_by_id == old_id).update(
{"created_by_id": new_id})
# Milestones
db.query(Milestone).filter(Milestone.created_by_id == old_id).update(
{"created_by_id": new_id})
# Calendar
db.query(TimeSlot).filter(TimeSlot.user_id == old_id).update(
{"user_id": new_id})
db.query(SchedulePlan).filter(SchedulePlan.user_id == old_id).update(
{"user_id": new_id})
# Minimum workload / Essential
db.query(MinimumWorkload).filter(MinimumWorkload.user_id == old_id).update(
{"user_id": new_id})
db.query(Essential).filter(Essential.created_by_id == old_id).update(
{"created_by_id": new_id})
# Agent profile
db.query(Agent).filter(Agent.user_id == old_id).update(
{"user_id": new_id})
@router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user( def delete_user(
identifier: str, identifier: str,
@@ -304,26 +223,17 @@ def delete_user(
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
if current_user.id == user.id: if current_user.id == user.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account") raise HTTPException(status_code=400, detail="You cannot delete your own account")
# Protect built-in accounts from deletion
if user.is_admin: if user.is_admin:
raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted") raise HTTPException(status_code=400, detail="Admin accounts cannot be deleted")
if user.username in _BUILTIN_USERNAMES: if user.username == "acc-mgr":
raise HTTPException( raise HTTPException(status_code=400, detail="The acc-mgr account is a built-in account and cannot be deleted")
status_code=400, try:
detail=f"The {user.username} account is a built-in account and cannot be deleted", db.delete(user)
) db.commit()
except IntegrityError:
deleted_user = db.query(models.User).filter( db.rollback()
models.User.username == DELETED_USER_USERNAME raise HTTPException(status_code=400, detail="User has related records. Deactivate the account instead.")
).first()
if not deleted_user:
raise HTTPException(
status_code=500,
detail="Built-in deleted-user account not found. Run init_wizard first.",
)
_reassign_user_references(db, user.id, deleted_user.id)
db.delete(user)
db.commit()
return None return None
@@ -331,7 +241,7 @@ def delete_user(
def reset_user_apikey( def reset_user_apikey(
identifier: str, identifier: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey), current_user: models.User = Depends(get_current_user),
): ):
"""Reset (regenerate) a user's API key. """Reset (regenerate) a user's API key.
@@ -339,8 +249,6 @@ def reset_user_apikey(
- user.reset-apikey: can reset any user's API key - user.reset-apikey: can reset any user's API key
- user.reset-self-apikey: can reset only own API key - user.reset-self-apikey: can reset only own API key
- admin: can reset any user's API key - admin: can reset any user's API key
Accepts both OAuth2 Bearer token and X-API-Key authentication.
""" """
import secrets import secrets
from app.models.apikey import APIKey from app.models.apikey import APIKey

View File

@@ -189,7 +189,6 @@ _DEV_PERMISSIONS = {
_ACCOUNT_MANAGER_PERMISSIONS = { _ACCOUNT_MANAGER_PERMISSIONS = {
"account.create", "account.create",
"user.reset-apikey",
} }
# Role definitions: (name, description, permission_set) # Role definitions: (name, description, permission_set)
@@ -295,39 +294,6 @@ def init_acc_mgr_user(db: Session) -> models.User | None:
return user return user
DELETED_USER_USERNAME = "deleted-user"
def init_deleted_user(db: Session) -> models.User | None:
"""Create the built-in deleted-user if not exists.
This user serves as a foreign key sink: when a real user is deleted,
all references are reassigned here instead of cascading deletes.
It has no role (no permissions) and cannot log in.
"""
existing = db.query(models.User).filter(
models.User.username == DELETED_USER_USERNAME
).first()
if existing:
logger.info("deleted-user already exists (id=%d), skipping", existing.id)
return existing
user = models.User(
username=DELETED_USER_USERNAME,
email="deleted-user@harborforge.internal",
full_name="Deleted User",
hashed_password=None,
is_admin=False,
is_active=False,
role_id=None,
)
db.add(user)
db.commit()
db.refresh(user)
logger.info("Created deleted-user (id=%d)", user.id)
return user
def run_init(db: Session) -> None: def run_init(db: Session) -> None:
"""Main initialization entry point. Reads config from shared volume.""" """Main initialization entry point. Reads config from shared volume."""
config = load_config() config = load_config()
@@ -352,9 +318,6 @@ def run_init(db: Session) -> None:
# Built-in acc-mgr user (after roles are created) # Built-in acc-mgr user (after roles are created)
init_acc_mgr_user(db) init_acc_mgr_user(db)
# Built-in deleted-user (foreign key sink for deleted accounts)
init_deleted_user(db)
# Default project # Default project
project_cfg = config.get("default_project") project_cfg = config.get("default_project")
if project_cfg and admin_user: if project_cfg and admin_user:

View File

@@ -63,6 +63,7 @@ from app.api.routers.proposes import router as proposes_router # legacy compat
from app.api.routers.milestone_actions import router as milestone_actions_router 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.meetings import router as meetings_router
from app.api.routers.essentials import router as essentials_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.calendar import router as calendar_router from app.api.routers.calendar import router as calendar_router
app.include_router(auth_router) app.include_router(auth_router)
@@ -80,6 +81,7 @@ app.include_router(proposes_router) # legacy compat
app.include_router(milestone_actions_router) app.include_router(milestone_actions_router)
app.include_router(meetings_router) app.include_router(meetings_router)
app.include_router(essentials_router) app.include_router(essentials_router)
app.include_router(schedule_type_router)
app.include_router(calendar_router) app.include_router(calendar_router)
@@ -363,6 +365,10 @@ def _migrate_schema():
if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"): if _has_table(db, "time_slots") and not _has_column(db, "time_slots", "wakeup_sent_at"):
db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL")) db.execute(text("ALTER TABLE time_slots ADD COLUMN wakeup_sent_at DATETIME NULL"))
# --- agents: add schedule_type_id FK ---
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"))
db.commit() db.commit()
except Exception as e: except Exception as e:
db.rollback() db.rollback()
@@ -397,7 +403,7 @@ def _sync_default_user_roles(db):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
from app.core.config import Base, engine, SessionLocal 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 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
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_migrate_schema() _migrate_schema()

View File

@@ -131,6 +131,15 @@ class Agent(Base):
comment="rate_limit | billing — why the agent is exhausted", comment="rate_limit | billing — why the agent is exhausted",
) )
# -- schedule type ------------------------------------------------------
schedule_type_id = Column(
Integer,
ForeignKey("schedule_types.id"),
nullable=True,
comment="FK to schedule_types — defines work/entertainment periods",
)
# -- timestamps --------------------------------------------------------- # -- timestamps ---------------------------------------------------------
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -138,3 +147,4 @@ class Agent(Base):
# -- relationships ------------------------------------------------------ # -- relationships ------------------------------------------------------
user = relationship("User", back_populates="agent", uselist=False) user = relationship("User", back_populates="agent", uselist=False)
schedule_type = relationship("ScheduleType", lazy="joined")

View File

@@ -0,0 +1,52 @@
"""ScheduleType model — defines work/entertainment 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.
"""
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from app.core.config import Base
class ScheduleType(Base):
"""Work/entertainment period definition."""
__tablename__ = "schedule_types"
id = Column(Integer, primary_key=True, index=True)
name = Column(
String(64),
nullable=False,
unique=True,
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
)
work_from = Column(
Integer,
nullable=False,
comment="Work period start hour (0-23, UTC)",
)
work_to = Column(
Integer,
nullable=False,
comment="Work period end hour (0-23, UTC)",
)
entertainment_from = Column(
Integer,
nullable=False,
comment="Entertainment period start hour (0-23, UTC)",
)
entertainment_to = Column(
Integer,
nullable=False,
comment="Entertainment period end hour (0-23, UTC)",
)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,36 @@
"""Schemas for ScheduleType CRUD."""
from pydantic import BaseModel, Field
from typing import Optional
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)
class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, le=23)
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)
class ScheduleTypeResponse(BaseModel):
id: int
name: str
work_from: int
work_to: int
entertainment_from: int
entertainment_to: int
class Config:
from_attributes = True
class AgentScheduleTypeAssign(BaseModel):
schedule_type_name: str = Field(..., description="Name of the schedule type to assign")