diff --git a/app/api/routers/users.py b/app/api/routers/users.py index 0aefad1..8aa4622 100644 --- a/app/api/routers/users.py +++ b/app/api/routers/users.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_password_hash from app.core.config import get_db from app.models import models +from app.models.agent import Agent from app.models.role_permission import Permission, Role, RolePermission from app.models.worklog import WorkLog from app.schemas import schemas @@ -17,6 +18,23 @@ from app.schemas import schemas router = APIRouter(prefix="/users", tags=["Users"]) +def _user_response(user: models.User) -> dict: + """Build a UserResponse-compatible dict that includes the agent_id when present.""" + data = { + "id": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, + "is_active": user.is_active, + "is_admin": user.is_admin, + "role_id": user.role_id, + "role_name": user.role_name, + "agent_id": user.agent.agent_id if user.agent else None, + "created_at": user.created_at, + } + return data + + def require_admin(current_user: models.User = Depends(get_current_user)): if not current_user.is_admin: raise HTTPException(status_code=403, detail="Admin required") @@ -69,12 +87,27 @@ def create_user( db: Session = Depends(get_db), _: models.User = Depends(require_account_creator), ): + # Validate agent_id / claw_identifier: both or neither + has_agent_id = bool(user.agent_id) + has_claw = bool(user.claw_identifier) + if has_agent_id != has_claw: + raise HTTPException( + status_code=400, + detail="agent_id and claw_identifier must both be provided or both omitted", + ) + existing = db.query(models.User).filter( (models.User.username == user.username) | (models.User.email == user.email) ).first() if existing: raise HTTPException(status_code=400, detail="Username or email already exists") + # Check agent_id uniqueness + if has_agent_id: + existing_agent = db.query(Agent).filter(Agent.agent_id == user.agent_id).first() + if existing_agent: + raise HTTPException(status_code=400, detail="agent_id already in use") + assigned_role = _resolve_user_role(db, user.role_id) hashed_password = get_password_hash(user.password) if user.password else None db_user = models.User( @@ -87,9 +120,20 @@ def create_user( role_id=assigned_role.id, ) db.add(db_user) + db.flush() # get db_user.id + + # Create Agent record if agent binding is requested (BE-CAL-003) + if has_agent_id: + db_agent = Agent( + user_id=db_user.id, + agent_id=user.agent_id, + claw_identifier=user.claw_identifier, + ) + db.add(db_agent) + db.commit() db.refresh(db_user) - return db_user + return _user_response(db_user) @router.get("", response_model=List[schemas.UserResponse]) @@ -99,7 +143,8 @@ def list_users( db: Session = Depends(get_db), _: models.User = Depends(require_admin), ): - return db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all() + users = db.query(models.User).order_by(models.User.created_at.desc()).offset(skip).limit(limit).all() + return [_user_response(u) for u in users] def _find_user_by_id_or_username(db: Session, identifier: str) -> models.User | None: @@ -120,7 +165,7 @@ def get_user( user = _find_user_by_id_or_username(db, identifier) if not user: raise HTTPException(status_code=404, detail="User not found") - return user + return _user_response(user) @router.patch("/{identifier}", response_model=schemas.UserResponse) @@ -159,7 +204,7 @@ def update_user( db.commit() db.refresh(user) - return user + return _user_response(user) @router.delete("/{identifier}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/main.py b/app/main.py index b2fd104..9627064 100644 --- a/app/main.py +++ b/app/main.py @@ -261,6 +261,27 @@ def _migrate_schema(): if _has_table(db, "server_states") and not _has_column(db, "server_states", "nginx_sites_json"): db.execute(text("ALTER TABLE server_states ADD COLUMN nginx_sites_json TEXT NULL")) + # --- agents table (BE-CAL-003) --- + if not _has_table(db, "agents"): + db.execute(text(""" + CREATE TABLE agents ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id INTEGER NOT NULL, + agent_id VARCHAR(128) NOT NULL, + claw_identifier VARCHAR(128) NOT NULL, + status ENUM('idle','on_call','busy','exhausted','offline') NOT NULL DEFAULT 'idle', + last_heartbeat DATETIME NULL, + exhausted_at DATETIME NULL, + recovery_at DATETIME NULL, + exhaust_reason ENUM('rate_limit','billing') NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE INDEX idx_agents_user_id (user_id), + UNIQUE INDEX idx_agents_agent_id (agent_id), + CONSTRAINT fk_agents_user_id FOREIGN KEY (user_id) REFERENCES users(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """)) + # --- essentials table (BE-PR-003) --- if not _has_table(db, "essentials"): db.execute(text(""" @@ -316,7 +337,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 + from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar Base.metadata.create_all(bind=engine) _migrate_schema() diff --git a/app/models/agent.py b/app/models/agent.py new file mode 100644 index 0000000..289e2d9 --- /dev/null +++ b/app/models/agent.py @@ -0,0 +1,140 @@ +"""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", + ) + + # -- timestamps --------------------------------------------------------- + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # -- relationships ------------------------------------------------------ + + user = relationship("User", back_populates="agent", uselist=False) diff --git a/app/models/models.py b/app/models/models.py index 24c0b12..b790154 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -81,6 +81,7 @@ class User(Base): owned_projects = relationship("Project", back_populates="owner") comments = relationship("Comment", back_populates="author") project_memberships = relationship("ProjectMember", back_populates="user") + agent = relationship("Agent", back_populates="user", uselist=False) @property def role_name(self): diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index d1fb556..6bd1443 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -176,6 +176,9 @@ class UserBase(BaseModel): class UserCreate(UserBase): password: Optional[str] = None role_id: Optional[int] = None + # Agent binding (both must be provided or both omitted) + agent_id: Optional[str] = None + claw_identifier: Optional[str] = None class UserUpdate(BaseModel): @@ -192,6 +195,7 @@ class UserResponse(UserBase): is_admin: bool role_id: Optional[int] = None role_name: Optional[str] = None + agent_id: Optional[str] = None created_at: datetime class Config: @@ -389,6 +393,47 @@ class ProposalAcceptResponse(ProposalResponse): from_attributes = True +# --------------------------------------------------------------------------- +# Agent schemas (BE-CAL-003) +# --------------------------------------------------------------------------- + +class AgentStatusEnum(str, Enum): + IDLE = "idle" + ON_CALL = "on_call" + BUSY = "busy" + EXHAUSTED = "exhausted" + OFFLINE = "offline" + + +class ExhaustReasonEnum(str, Enum): + RATE_LIMIT = "rate_limit" + BILLING = "billing" + + +class AgentResponse(BaseModel): + """Read-only representation of an Agent.""" + id: int + user_id: int + agent_id: str + claw_identifier: str + status: AgentStatusEnum + last_heartbeat: Optional[datetime] = None + exhausted_at: Optional[datetime] = None + recovery_at: Optional[datetime] = None + exhaust_reason: Optional[ExhaustReasonEnum] = None + created_at: datetime + + class Config: + from_attributes = True + + +class AgentStatusUpdate(BaseModel): + """Payload for updating an agent's runtime status.""" + status: AgentStatusEnum + exhaust_reason: Optional[ExhaustReasonEnum] = None + recovery_at: Optional[datetime] = None + + # Backward-compatible aliases ProposeStatusEnum = ProposalStatusEnum ProposeBase = ProposalBase