Files
HarborForge.Backend/app/models/agent.py
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

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")