feat(users): PATCH /users/{id}/bind-agent to backfill agents row

Companion endpoint for the cli's upcoming `hf user bind-agent` subcommand.
Lets admin retroactively bind an existing user to (agent_id,
claw_identifier) when that user was created before `hf user create`
supported the binding flags (i.e. all of zhi/lyn/mirror/sherlock/orion/
nav on prod today — agents table has 0 rows even though their user rows
exist).

Schema:
  PATCH /users/{identifier}/bind-agent
  body: {agent_id: str, claw_identifier: str}  // both required
  perm: account.create (admin auto)            // same as POST /users

Behaviour:
  * idempotent: re-bind to the same (agent_id, claw_identifier) → 200
    no-op, no extra row
  * 409 if user is already bound to a different pair
  * 409 if requested agent_id is already in use by another user
  * creates the agents row inline; subsequent /schedule-types/agent/
    {agent_id}/assign etc. then work normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
h z
2026-05-22 19:58:06 +01:00
parent 5b59806e38
commit 6400f7f612
2 changed files with 79 additions and 1 deletions

View File

@@ -221,6 +221,71 @@ def update_user(
return _user_response(user) return _user_response(user)
@router.patch("/{identifier}/bind-agent", response_model=schemas.UserResponse)
def bind_agent(
identifier: str,
payload: schemas.UserBindAgentRequest,
db: Session = Depends(get_db),
_: models.User = Depends(require_account_creator),
):
"""Bind an existing user to (agent_id, claw_identifier).
Backfill path for users that were created via `hf user create` before
the cli supported `--agent-id` / `--claw-identifier` flags. Creates
the `agents` row that should have been written at user-create time.
Idempotent: if the user is already bound to the same
(agent_id, claw_identifier), returns the user unchanged (200, no-op).
Rejects (409) if:
- the user is bound to a DIFFERENT (agent_id, claw_identifier)
- the requested agent_id is already in use by another user
Permission: account.create (admin auto-grants) — same gate as
POST /users so the surface stays symmetric.
"""
user = _find_user_by_id_or_username(db, identifier)
if not user:
raise HTTPException(status_code=404, detail="User not found")
existing_agent_for_user = db.query(Agent).filter(Agent.user_id == user.id).first()
if existing_agent_for_user:
if (
existing_agent_for_user.agent_id == payload.agent_id
and existing_agent_for_user.claw_identifier == payload.claw_identifier
):
# idempotent re-bind
return _user_response(user)
raise HTTPException(
status_code=409,
detail=(
f"User '{user.username}' is already bound to agent "
f"'{existing_agent_for_user.agent_id}' on claw "
f"'{existing_agent_for_user.claw_identifier}'"
),
)
existing_for_agent_id = (
db.query(Agent).filter(Agent.agent_id == payload.agent_id).first()
)
if existing_for_agent_id:
raise HTTPException(
status_code=409,
detail=f"agent_id '{payload.agent_id}' already in use by another user",
)
db.add(
Agent(
user_id=user.id,
agent_id=payload.agent_id,
claw_identifier=payload.claw_identifier,
)
)
db.commit()
db.refresh(user)
return _user_response(user)
_BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME} _BUILTIN_USERNAMES = {"acc-mgr", DELETED_USER_USERNAME}

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime, time from datetime import datetime, time
from enum import Enum from enum import Enum
@@ -186,6 +186,19 @@ class UserUpdate(BaseModel):
discord_user_id: Optional[str] = None discord_user_id: Optional[str] = None
class UserBindAgentRequest(BaseModel):
"""Request body for PATCH /users/{identifier}/bind-agent.
Binds an existing user to (agent_id, claw_identifier) by inserting a
row in the `agents` table. Both fields required (mirrors the
create-time invariant in UserCreate). Idempotent: re-binding the same
user to the same (agent_id, claw_identifier) returns the existing
Agent row instead of 409.
"""
agent_id: str = Field(..., min_length=1, max_length=128)
claw_identifier: str = Field(..., min_length=1, max_length=128)
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool