feat(boards): Store gateway config per board

Move gateway configuration into board settings and wire agent\nprovisioning, heartbeat templates, and gateway status lookups\nto use board-specific gateway settings. Adds board_id on agents\nand UI updates for board-scoped selection.\n\nCo-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Abhimanyu Saharan
2026-02-04 16:04:52 +05:30
parent 12698d0781
commit 4dea771545
20 changed files with 827 additions and 196 deletions

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re
from datetime import datetime, timedelta
from uuid import uuid4
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, col, select
@@ -11,9 +11,9 @@ from sqlalchemy import update
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.config import settings
from app.db.session import get_session
from app.integrations.openclaw_gateway import (
GatewayConfig,
OpenClawGatewayError,
delete_session,
ensure_session,
@@ -21,6 +21,7 @@ from app.integrations.openclaw_gateway import (
)
from app.models.agents import Agent
from app.models.activity_events import ActivityEvent
from app.models.boards import Board
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
@@ -46,10 +47,34 @@ def _build_session_key(agent_name: str) -> str:
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]:
def _require_board(session: Session, board_id: UUID | str | None) -> Board:
if not board_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_id is required",
)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
return board
def _require_gateway_config(board: Board) -> GatewayConfig:
if not board.gateway_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_url is required",
)
return GatewayConfig(url=board.gateway_url, token=board.gateway_token)
async def _ensure_gateway_session(
agent_name: str,
config: GatewayConfig,
) -> tuple[str, str | None]:
session_key = _build_session_key(agent_name)
try:
await ensure_session(session_key, label=agent_name)
await ensure_session(session_key, config=config, label=agent_name)
return session_key, None
except OpenClawGatewayError as exc:
return session_key, str(exc)
@@ -97,11 +122,13 @@ async def create_agent(
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Agent:
board = _require_board(session, payload.board_id)
config = _require_gateway_config(board)
agent = Agent.model_validate(payload)
agent.status = "provisioning"
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name)
session_key, session_error = await _ensure_gateway_session(agent.name, config)
agent.openclaw_session_id = session_key
session.add(agent)
session.commit()
@@ -122,7 +149,7 @@ async def create_agent(
)
session.commit()
try:
await send_provisioning_message(agent, raw_token)
await send_provisioning_message(agent, board, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
@@ -160,6 +187,8 @@ def update_agent(
status_code=status.HTTP_403_FORBIDDEN,
detail="status is controlled by agent heartbeat",
)
if "board_id" in updates and updates["board_id"]:
_require_board(session, str(updates["board_id"]))
for key, value in updates.items():
setattr(agent, key, value)
agent.updated_at = datetime.utcnow()
@@ -204,10 +233,12 @@ async def heartbeat_or_create_agent(
if agent is None:
if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
agent = Agent(name=payload.name, status="provisioning")
board = _require_board(session, payload.board_id)
config = _require_gateway_config(board)
agent = Agent(name=payload.name, status="provisioning", board_id=board.id)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name)
session_key, session_error = await _ensure_gateway_session(agent.name, config)
agent.openclaw_session_id = session_key
session.add(agent)
session.commit()
@@ -228,7 +259,7 @@ async def heartbeat_or_create_agent(
)
session.commit()
try:
await send_provisioning_message(agent, raw_token)
await send_provisioning_message(agent, board, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
@@ -244,7 +275,9 @@ async def heartbeat_or_create_agent(
session.commit()
session.refresh(agent)
try:
await send_provisioning_message(agent, raw_token)
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
_require_gateway_config(board)
await send_provisioning_message(agent, board, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
@@ -252,7 +285,9 @@ async def heartbeat_or_create_agent(
_record_provisioning_failure(session, agent, str(exc))
session.commit()
elif not agent.openclaw_session_id:
session_key, session_error = await _ensure_gateway_session(agent.name)
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
config = _require_gateway_config(board)
session_key, session_error = await _ensure_gateway_session(agent.name, config)
agent.openclaw_session_id = session_key
if session_error:
record_activity(
@@ -290,12 +325,16 @@ def delete_agent(
) -> dict[str, bool]:
agent = session.get(Agent, agent_id)
if agent:
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
config = _require_gateway_config(board)
async def _gateway_cleanup() -> None:
if agent.openclaw_session_id:
await delete_session(agent.openclaw_session_id)
main_session = settings.openclaw_main_session_key
await delete_session(agent.openclaw_session_id, config=config)
main_session = board.gateway_main_session_key or "agent:main:main"
if main_session:
workspace_root = settings.openclaw_workspace_root or "~/.openclaw/workspaces"
workspace_root = (
board.gateway_workspace_root or "~/.openclaw/workspaces"
)
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
cleanup_message = (
"Cleanup request for deleted agent.\n\n"
@@ -308,8 +347,13 @@ def delete_agent(
"2) Delete any lingering session artifacts.\n"
"Reply NO_REPLY."
)
await ensure_session(main_session, label="Main Agent")
await send_message(cleanup_message, session_key=main_session, deliver=False)
await ensure_session(main_session, config=config, label="Main Agent")
await send_message(
cleanup_message,
session_key=main_session,
config=config,
deliver=False,
)
try:
import asyncio

View File

@@ -31,7 +31,10 @@ def create_board(
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Board:
board = Board.model_validate(payload)
data = payload.model_dump()
if data.get("gateway_token") == "":
data["gateway_token"] = None
board = Board.model_validate(data)
session.add(board)
session.commit()
session.refresh(board)
@@ -54,6 +57,8 @@ def update_board(
auth: AuthContext = Depends(require_admin_auth),
) -> Board:
updates = payload.model_dump(exclude_unset=True)
if updates.get("gateway_token") == "":
updates["gateway_token"] = None
for key, value in updates.items():
setattr(board, key, value)
session.add(board)

View File

@@ -1,43 +1,69 @@
from __future__ import annotations
from fastapi import APIRouter, Body, Depends, HTTPException, status
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlmodel import Session
from app.api.deps import require_admin_auth
from app.core.auth import AuthContext
from app.core.config import settings
from app.integrations.openclaw_gateway import (
GatewayConfig,
OpenClawGatewayError,
ensure_session,
get_chat_history,
openclaw_call,
send_message,
)
from app.db.session import get_session
from app.models.boards import Board
router = APIRouter(prefix="/gateway", tags=["gateway"])
def _require_board_config(session: Session, board_id: str | None) -> tuple[Board, GatewayConfig]:
if not board_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_id is required",
)
board = session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if not board.gateway_url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_url is required",
)
return board, GatewayConfig(url=board.gateway_url, token=board.gateway_token)
@router.get("/status")
async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dict[str, object]:
gateway_url = settings.openclaw_gateway_url or "ws://127.0.0.1:18789"
async def gateway_status(
board_id: str | None = Query(default=None),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, object]:
board, config = _require_board_config(session, board_id)
try:
sessions = await openclaw_call("sessions.list")
sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
main_session = board.gateway_main_session_key or "agent:main:main"
main_session_entry: object | None = None
main_session_error: str | None = None
if main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
ensured = await ensure_session(
main_session, config=config, label="Main Agent"
)
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError as exc:
main_session_error = str(exc)
return {
"connected": True,
"gateway_url": gateway_url,
"gateway_url": board.gateway_url,
"sessions_count": len(sessions_list),
"sessions": sessions_list,
"main_session_key": main_session,
@@ -47,15 +73,20 @@ async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dic
except OpenClawGatewayError as exc:
return {
"connected": False,
"gateway_url": gateway_url,
"gateway_url": board.gateway_url,
"error": str(exc),
}
@router.get("/sessions")
async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict[str, object]:
async def list_sessions(
board_id: str | None = Query(default=None),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, object]:
board, config = _require_board_config(session, board_id)
try:
sessions = await openclaw_call("sessions.list")
sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
@@ -63,63 +94,79 @@ async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
main_session = board.gateway_main_session_key or "agent:main:main"
main_session_entry: object | None = None
if main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
ensured = await ensure_session(
main_session, config=config, label="Main Agent"
)
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError:
main_session_entry = None
return {"sessions": sessions_list, "main_session_key": main_session, "main_session": main_session_entry}
return {
"sessions": sessions_list,
"main_session_key": main_session,
"main_session": main_session_entry,
}
@router.get("/sessions/{session_id}")
async def get_session(
session_id: str, auth: AuthContext = Depends(require_admin_auth)
async def get_gateway_session(
session_id: str,
board_id: str | None = Query(default=None),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, object]:
board, config = _require_board_config(session, board_id)
try:
sessions = await openclaw_call("sessions.list")
sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
main_session = board.gateway_main_session_key or "agent:main:main"
if main_session and not any(
session.get("key") == main_session for session in sessions_list
):
try:
await ensure_session(main_session, label="Main Agent")
refreshed = await openclaw_call("sessions.list")
await ensure_session(main_session, config=config, label="Main Agent")
refreshed = await openclaw_call("sessions.list", config=config)
if isinstance(refreshed, dict):
sessions_list = list(refreshed.get("sessions") or [])
else:
sessions_list = list(refreshed or [])
except OpenClawGatewayError:
pass
session = next((item for item in sessions_list if item.get("key") == session_id), None)
if session is None and main_session and session_id == main_session:
session_entry = next(
(item for item in sessions_list if item.get("key") == session_id), None
)
if session_entry is None and main_session and session_id == main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
ensured = await ensure_session(main_session, config=config, label="Main Agent")
if isinstance(ensured, dict):
session = ensured.get("entry") or ensured
session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError:
session = None
if session is None:
session_entry = None
if session_entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return {"session": session}
return {"session": session_entry}
@router.get("/sessions/{session_id}/history")
async def get_session_history(
session_id: str, auth: AuthContext = Depends(require_admin_auth)
session_id: str,
board_id: str | None = Query(default=None),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, object]:
_, config = _require_board_config(session, board_id)
try:
history = await get_chat_history(session_id)
history = await get_chat_history(session_id, config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(history, dict) and isinstance(history.get("messages"), list):
@@ -131,6 +178,8 @@ async def get_session_history(
async def send_session_message(
session_id: str,
payload: dict = Body(...),
board_id: str | None = Query(default=None),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> dict[str, bool]:
content = payload.get("content")
@@ -138,11 +187,12 @@ async def send_session_message(
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
)
board, config = _require_board_config(session, board_id)
try:
main_session = settings.openclaw_main_session_key
main_session = board.gateway_main_session_key or "agent:main:main"
if main_session and session_id == main_session:
await ensure_session(main_session, label="Main Agent")
await send_message(content, session_key=session_id)
await ensure_session(main_session, config=config, label="Main Agent")
await send_message(content, session_key=session_id, config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
return {"ok": True}