HarborForge.Backend: dev-2026-03-29 -> main #13

Merged
hzhang merged 43 commits from dev-2026-03-29 into main 2026-04-05 22:08:15 +00:00
5 changed files with 257 additions and 5 deletions
Showing only changes of commit 1c062ff4f1 - Show all commits

View File

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

View File

@@ -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()

140
app/models/agent.py Normal file
View File

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

View File

@@ -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):

View File

@@ -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