- 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>
151 lines
4.5 KiB
Python
151 lines
4.5 KiB
Python
"""Agent model — tracks OpenClaw agents linked to HarborForge users.
|
|
|
|
An Agent represents an AI agent (identified by its OpenClaw ``agent_id``)
|
|
that is bound to exactly one HarborForge User. The Calendar system uses
|
|
Agent status to decide whether to wake an agent for scheduled slots.
|
|
|
|
See: NEXT_WAVE_DEV_DIRECTION.md §1.4 (Agent table) and §6 (Agent wakeup)
|
|
Implements: BE-CAL-003
|
|
"""
|
|
|
|
from sqlalchemy import (
|
|
Column,
|
|
Integer,
|
|
String,
|
|
DateTime,
|
|
Enum,
|
|
ForeignKey,
|
|
)
|
|
from sqlalchemy.orm import relationship
|
|
from sqlalchemy.sql import func
|
|
from app.core.config import Base
|
|
import enum
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Enums
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class AgentStatus(str, enum.Enum):
|
|
"""Runtime status of an Agent."""
|
|
IDLE = "idle"
|
|
ON_CALL = "on_call"
|
|
BUSY = "busy"
|
|
EXHAUSTED = "exhausted"
|
|
OFFLINE = "offline"
|
|
|
|
|
|
class ExhaustReason(str, enum.Enum):
|
|
"""Why an agent entered the Exhausted state."""
|
|
RATE_LIMIT = "rate_limit"
|
|
BILLING = "billing"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agent model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class Agent(Base):
|
|
"""An OpenClaw agent bound to a HarborForge user.
|
|
|
|
Fields
|
|
------
|
|
user_id : int
|
|
One-to-one FK to ``users.id``. Each user has at most one agent.
|
|
agent_id : str
|
|
The ``$AGENT_ID`` value from OpenClaw (globally unique).
|
|
claw_identifier : str
|
|
The OpenClaw instance identifier (matches ``MonitoredServer.identifier``
|
|
by convention, but has no FK — they are independent concepts).
|
|
status : AgentStatus
|
|
Current runtime status, managed by heartbeat / calendar wakeup logic.
|
|
last_heartbeat : datetime | None
|
|
Timestamp of the most recent heartbeat received from this agent.
|
|
exhausted_at : datetime | None
|
|
When the agent entered the ``EXHAUSTED`` state.
|
|
recovery_at : datetime | None
|
|
Estimated time the agent will recover from ``EXHAUSTED`` → ``IDLE``.
|
|
exhaust_reason : ExhaustReason | None
|
|
Why the agent became exhausted (rate-limit vs billing).
|
|
"""
|
|
|
|
__tablename__ = "agents"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
user_id = Column(
|
|
Integer,
|
|
ForeignKey("users.id"),
|
|
nullable=False,
|
|
unique=True,
|
|
index=True,
|
|
comment="1-to-1 link to the owning HarborForge user",
|
|
)
|
|
|
|
agent_id = Column(
|
|
String(128),
|
|
nullable=False,
|
|
unique=True,
|
|
index=True,
|
|
comment="OpenClaw $AGENT_ID",
|
|
)
|
|
|
|
claw_identifier = Column(
|
|
String(128),
|
|
nullable=False,
|
|
comment="OpenClaw instance identifier (same value as MonitoredServer.identifier by convention)",
|
|
)
|
|
|
|
# -- runtime status fields ----------------------------------------------
|
|
|
|
status = Column(
|
|
Enum(AgentStatus, values_callable=lambda x: [e.value for e in x]),
|
|
nullable=False,
|
|
default=AgentStatus.IDLE,
|
|
comment="Current agent status: idle | on_call | busy | exhausted | offline",
|
|
)
|
|
|
|
last_heartbeat = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="Timestamp of the most recent heartbeat",
|
|
)
|
|
|
|
# -- exhausted state detail ---------------------------------------------
|
|
|
|
exhausted_at = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="When the agent entered EXHAUSTED state",
|
|
)
|
|
|
|
recovery_at = Column(
|
|
DateTime(timezone=True),
|
|
nullable=True,
|
|
comment="Estimated recovery time from EXHAUSTED → IDLE",
|
|
)
|
|
|
|
exhaust_reason = Column(
|
|
Enum(ExhaustReason, values_callable=lambda x: [e.value for e in x]),
|
|
nullable=True,
|
|
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 ---------------------------------------------------------
|
|
|
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
# -- relationships ------------------------------------------------------
|
|
|
|
user = relationship("User", back_populates="agent", uselist=False)
|
|
schedule_type = relationship("ScheduleType", lazy="joined")
|