BE-CAL-003: Add Agent model with status/heartbeat/exhausted fields

- New app/models/agent.py with Agent model, AgentStatus & ExhaustReason enums
- Agent has 1-to-1 FK to User, unique agent_id (OpenClaw $AGENT_ID),
  claw_identifier (OpenClaw instance, convention-matches MonitoredServer.identifier)
- Status fields: status (idle/on_call/busy/exhausted/offline), last_heartbeat
- Exhausted tracking: exhausted_at, recovery_at, exhaust_reason (rate_limit/billing)
- User model: added 'agent' back-reference (uselist=False)
- Schemas: AgentResponse, AgentStatusUpdate, UserCreate now accepts agent_id+claw_identifier
- UserResponse: includes agent_id when agent is bound
- Users router: create_user creates Agent record when agent_id+claw_identifier provided
- Auto-migration: CREATE TABLE agents in _migrate_schema()
- Startup imports: agent and calendar models registered
This commit is contained in:
zhi
2026-03-30 20:47:44 +00:00
parent a9b4fa14b4
commit 1c062ff4f1
5 changed files with 257 additions and 5 deletions

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)