2026-02-04 02:28:51 +05:30
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-02-04 16:15:01 +05:30
|
|
|
import re
|
2026-02-06 19:11:11 +05:30
|
|
|
from uuid import UUID, uuid4
|
2026-02-04 16:15:01 +05:30
|
|
|
|
2026-02-06 19:11:11 +05:30
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
2026-02-07 00:21:44 +05:30
|
|
|
from sqlalchemy import delete, func
|
2026-02-06 16:12:04 +05:30
|
|
|
from sqlmodel import col, select
|
|
|
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
2026-02-04 02:28:51 +05:30
|
|
|
|
2026-02-08 21:16:26 +05:30
|
|
|
from app.api.deps import (
|
|
|
|
|
get_board_for_actor_read,
|
|
|
|
|
get_board_for_user_read,
|
|
|
|
|
get_board_for_user_write,
|
|
|
|
|
require_org_admin,
|
|
|
|
|
require_org_member,
|
|
|
|
|
)
|
2026-02-06 16:12:04 +05:30
|
|
|
from app.core.time import utcnow
|
|
|
|
|
from app.db import crud
|
2026-02-06 19:11:11 +05:30
|
|
|
from app.db.pagination import paginate
|
2026-02-04 02:28:51 +05:30
|
|
|
from app.db.session import get_session
|
2026-02-05 00:21:33 +05:30
|
|
|
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
2026-02-04 16:15:01 +05:30
|
|
|
from app.integrations.openclaw_gateway import (
|
|
|
|
|
OpenClawGatewayError,
|
|
|
|
|
delete_session,
|
|
|
|
|
ensure_session,
|
|
|
|
|
send_message,
|
|
|
|
|
)
|
|
|
|
|
from app.models.activity_events import ActivityEvent
|
|
|
|
|
from app.models.agents import Agent
|
2026-02-05 15:29:41 +05:30
|
|
|
from app.models.approvals import Approval
|
2026-02-07 20:29:50 +05:30
|
|
|
from app.models.board_groups import BoardGroup
|
2026-02-05 15:29:41 +05:30
|
|
|
from app.models.board_memory import BoardMemory
|
|
|
|
|
from app.models.board_onboarding import BoardOnboardingSession
|
2026-02-04 02:28:51 +05:30
|
|
|
from app.models.boards import Board
|
2026-02-04 23:07:22 +05:30
|
|
|
from app.models.gateways import Gateway
|
2026-02-07 02:42:33 +05:30
|
|
|
from app.models.task_dependencies import TaskDependency
|
2026-02-05 15:29:41 +05:30
|
|
|
from app.models.task_fingerprints import TaskFingerprint
|
2026-02-04 16:15:01 +05:30
|
|
|
from app.models.tasks import Task
|
2026-02-04 02:28:51 +05:30
|
|
|
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
2026-02-07 00:21:44 +05:30
|
|
|
from app.schemas.common import OkResponse
|
2026-02-06 19:11:11 +05:30
|
|
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
2026-02-07 20:29:50 +05:30
|
|
|
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
|
|
|
|
|
from app.services.board_group_snapshot import build_board_group_snapshot
|
2026-02-06 19:11:11 +05:30
|
|
|
from app.services.board_snapshot import build_board_snapshot
|
2026-02-08 21:37:20 +05:30
|
|
|
from app.services.organizations import OrganizationContext, board_access_filter
|
2026-02-04 02:28:51 +05:30
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/boards", tags=["boards"])
|
|
|
|
|
|
2026-02-04 16:15:01 +05:30
|
|
|
AGENT_SESSION_PREFIX = "agent"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _slugify(value: str) -> str:
|
|
|
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
|
|
|
return slug or uuid4().hex
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _build_session_key(agent_name: str) -> str:
|
|
|
|
|
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
|
|
|
|
|
|
|
|
|
|
|
2026-02-08 21:16:26 +05:30
|
|
|
async def _require_gateway(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
gateway_id: object,
|
|
|
|
|
*,
|
|
|
|
|
organization_id: UUID | None = None,
|
|
|
|
|
) -> Gateway:
|
2026-02-06 16:12:04 +05:30
|
|
|
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
|
|
|
|
if gateway is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="gateway_id is invalid",
|
|
|
|
|
)
|
2026-02-08 21:16:26 +05:30
|
|
|
if organization_id is not None and gateway.organization_id != organization_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="gateway_id is invalid",
|
|
|
|
|
)
|
2026-02-06 16:12:04 +05:30
|
|
|
return gateway
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _require_gateway_for_create(
|
|
|
|
|
payload: BoardCreate,
|
2026-02-08 21:37:20 +05:30
|
|
|
ctx: OrganizationContext = Depends(require_org_admin),
|
2026-02-06 16:12:04 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> Gateway:
|
2026-02-08 21:16:26 +05:30
|
|
|
return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id)
|
2026-02-06 16:12:04 +05:30
|
|
|
|
|
|
|
|
|
2026-02-08 21:16:26 +05:30
|
|
|
async def _require_board_group(
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
board_group_id: object,
|
|
|
|
|
*,
|
|
|
|
|
organization_id: UUID | None = None,
|
|
|
|
|
) -> BoardGroup:
|
2026-02-07 20:29:50 +05:30
|
|
|
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
|
|
|
|
if group is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="board_group_id is invalid",
|
|
|
|
|
)
|
2026-02-08 21:16:26 +05:30
|
|
|
if organization_id is not None and group.organization_id != organization_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="board_group_id is invalid",
|
|
|
|
|
)
|
2026-02-07 20:29:50 +05:30
|
|
|
return group
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _require_board_group_for_create(
|
|
|
|
|
payload: BoardCreate,
|
2026-02-08 21:37:20 +05:30
|
|
|
ctx: OrganizationContext = Depends(require_org_admin),
|
2026-02-07 20:29:50 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> BoardGroup | None:
|
|
|
|
|
if payload.board_group_id is None:
|
|
|
|
|
return None
|
2026-02-08 21:16:26 +05:30
|
|
|
return await _require_board_group(
|
|
|
|
|
session,
|
|
|
|
|
payload.board_group_id,
|
|
|
|
|
organization_id=ctx.organization.id,
|
|
|
|
|
)
|
2026-02-07 20:29:50 +05:30
|
|
|
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
async def _apply_board_update(
|
|
|
|
|
*,
|
|
|
|
|
payload: BoardUpdate,
|
|
|
|
|
session: AsyncSession,
|
|
|
|
|
board: Board,
|
|
|
|
|
) -> Board:
|
|
|
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
|
|
|
if "gateway_id" in updates:
|
2026-02-08 21:17:26 +05:30
|
|
|
await _require_gateway(
|
|
|
|
|
session, updates["gateway_id"], organization_id=board.organization_id
|
|
|
|
|
)
|
2026-02-07 20:29:50 +05:30
|
|
|
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
2026-02-08 21:16:26 +05:30
|
|
|
await _require_board_group(
|
|
|
|
|
session,
|
|
|
|
|
updates["board_group_id"],
|
|
|
|
|
organization_id=board.organization_id,
|
|
|
|
|
)
|
2026-02-06 16:12:04 +05:30
|
|
|
for key, value in updates.items():
|
|
|
|
|
setattr(board, key, value)
|
|
|
|
|
if updates.get("board_type") == "goal":
|
|
|
|
|
# Validate only when explicitly switching to goal boards.
|
|
|
|
|
if not board.objective or not board.success_metrics:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="Goal boards require objective and success_metrics",
|
|
|
|
|
)
|
|
|
|
|
if not board.gateway_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="gateway_id is required",
|
|
|
|
|
)
|
|
|
|
|
board.updated_at = utcnow()
|
|
|
|
|
return await crud.save(session, board)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _board_gateway(
|
|
|
|
|
session: AsyncSession, board: Board
|
2026-02-04 23:07:22 +05:30
|
|
|
) -> tuple[Gateway | None, GatewayClientConfig | None]:
|
|
|
|
|
if not board.gateway_id:
|
|
|
|
|
return None, None
|
2026-02-06 16:12:04 +05:30
|
|
|
config = await session.get(Gateway, board.gateway_id)
|
2026-02-04 23:07:22 +05:30
|
|
|
if config is None:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
2026-02-04 23:07:22 +05:30
|
|
|
detail="Board gateway_id is invalid",
|
2026-02-04 17:14:47 +05:30
|
|
|
)
|
2026-02-04 23:07:22 +05:30
|
|
|
if not config.main_session_key:
|
2026-02-04 17:14:47 +05:30
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
2026-02-04 23:07:22 +05:30
|
|
|
detail="Gateway main_session_key is required",
|
2026-02-04 17:14:47 +05:30
|
|
|
)
|
2026-02-04 23:07:22 +05:30
|
|
|
if not config.url:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="Gateway url is required",
|
|
|
|
|
)
|
|
|
|
|
if not config.workspace_root:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
|
|
|
detail="Gateway workspace_root is required",
|
|
|
|
|
)
|
|
|
|
|
return config, GatewayClientConfig(url=config.url, token=config.token)
|
2026-02-04 16:15:01 +05:30
|
|
|
|
|
|
|
|
|
2026-02-04 23:07:22 +05:30
|
|
|
async def _cleanup_agent_on_gateway(
|
|
|
|
|
agent: Agent,
|
|
|
|
|
config: Gateway,
|
|
|
|
|
client_config: GatewayClientConfig,
|
|
|
|
|
) -> None:
|
2026-02-04 16:15:01 +05:30
|
|
|
if agent.openclaw_session_id:
|
2026-02-04 23:07:22 +05:30
|
|
|
await delete_session(agent.openclaw_session_id, config=client_config)
|
|
|
|
|
main_session = config.main_session_key
|
|
|
|
|
workspace_root = config.workspace_root
|
2026-02-04 17:14:47 +05:30
|
|
|
workspace_path = f"{workspace_root.rstrip('/')}/workspace-{_slugify(agent.name)}"
|
2026-02-04 16:15:01 +05:30
|
|
|
cleanup_message = (
|
|
|
|
|
"Cleanup request for deleted agent.\n\n"
|
|
|
|
|
f"Agent name: {agent.name}\n"
|
|
|
|
|
f"Agent id: {agent.id}\n"
|
|
|
|
|
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
|
|
|
|
|
f"Workspace path: {workspace_path}\n\n"
|
|
|
|
|
"Actions:\n"
|
|
|
|
|
"1) Remove the workspace directory.\n"
|
|
|
|
|
"2) Delete any lingering session artifacts.\n"
|
|
|
|
|
"Reply NO_REPLY."
|
|
|
|
|
)
|
2026-02-04 23:07:22 +05:30
|
|
|
await ensure_session(main_session, config=client_config, label="Main Agent")
|
|
|
|
|
await send_message(
|
|
|
|
|
cleanup_message,
|
|
|
|
|
session_key=main_session,
|
|
|
|
|
config=client_config,
|
|
|
|
|
deliver=False,
|
|
|
|
|
)
|
2026-02-04 16:15:01 +05:30
|
|
|
|
2026-02-04 02:28:51 +05:30
|
|
|
|
2026-02-06 19:11:11 +05:30
|
|
|
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
|
2026-02-06 16:12:04 +05:30
|
|
|
async def list_boards(
|
2026-02-06 19:11:11 +05:30
|
|
|
gateway_id: UUID | None = Query(default=None),
|
2026-02-07 20:29:50 +05:30
|
|
|
board_group_id: UUID | None = Query(default=None),
|
2026-02-06 16:12:04 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-02-08 21:37:20 +05:30
|
|
|
ctx: OrganizationContext = Depends(require_org_member),
|
2026-02-06 19:11:11 +05:30
|
|
|
) -> DefaultLimitOffsetPage[BoardRead]:
|
2026-02-08 21:16:26 +05:30
|
|
|
statement = select(Board).where(board_access_filter(ctx.member, write=False))
|
2026-02-06 19:11:11 +05:30
|
|
|
if gateway_id is not None:
|
|
|
|
|
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
2026-02-07 20:29:50 +05:30
|
|
|
if board_group_id is not None:
|
|
|
|
|
statement = statement.where(col(Board.board_group_id) == board_group_id)
|
2026-02-06 19:11:11 +05:30
|
|
|
statement = statement.order_by(func.lower(col(Board.name)).asc(), col(Board.created_at).desc())
|
|
|
|
|
return await paginate(session, statement)
|
2026-02-04 02:28:51 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("", response_model=BoardRead)
|
2026-02-06 16:12:04 +05:30
|
|
|
async def create_board(
|
2026-02-04 02:28:51 +05:30
|
|
|
payload: BoardCreate,
|
2026-02-06 16:12:04 +05:30
|
|
|
_gateway: Gateway = Depends(_require_gateway_for_create),
|
2026-02-07 20:29:50 +05:30
|
|
|
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
|
2026-02-06 16:12:04 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-02-08 21:37:20 +05:30
|
|
|
ctx: OrganizationContext = Depends(require_org_admin),
|
2026-02-04 02:28:51 +05:30
|
|
|
) -> Board:
|
2026-02-08 21:16:26 +05:30
|
|
|
data = payload.model_dump()
|
|
|
|
|
data["organization_id"] = ctx.organization.id
|
|
|
|
|
return await crud.create(session, Board, **data)
|
2026-02-04 02:28:51 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/{board_id}", response_model=BoardRead)
|
|
|
|
|
def get_board(
|
2026-02-08 21:16:26 +05:30
|
|
|
board: Board = Depends(get_board_for_user_read),
|
2026-02-04 02:28:51 +05:30
|
|
|
) -> Board:
|
|
|
|
|
return board
|
|
|
|
|
|
|
|
|
|
|
2026-02-06 19:11:11 +05:30
|
|
|
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
|
|
|
|
async def get_board_snapshot(
|
2026-02-08 21:16:26 +05:30
|
|
|
board: Board = Depends(get_board_for_actor_read),
|
2026-02-06 19:11:11 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> BoardSnapshot:
|
|
|
|
|
return await build_board_snapshot(session, board)
|
|
|
|
|
|
|
|
|
|
|
2026-02-07 20:29:50 +05:30
|
|
|
@router.get("/{board_id}/group-snapshot", response_model=BoardGroupSnapshot)
|
|
|
|
|
async def get_board_group_snapshot(
|
|
|
|
|
include_self: bool = Query(default=False),
|
|
|
|
|
include_done: bool = Query(default=False),
|
|
|
|
|
per_board_task_limit: int = Query(default=5, ge=0, le=100),
|
2026-02-08 21:16:26 +05:30
|
|
|
board: Board = Depends(get_board_for_actor_read),
|
2026-02-07 20:29:50 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> BoardGroupSnapshot:
|
|
|
|
|
return await build_board_group_snapshot(
|
|
|
|
|
session,
|
|
|
|
|
board=board,
|
|
|
|
|
include_self=include_self,
|
|
|
|
|
include_done=include_done,
|
|
|
|
|
per_board_task_limit=per_board_task_limit,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-02-04 02:28:51 +05:30
|
|
|
@router.patch("/{board_id}", response_model=BoardRead)
|
2026-02-06 16:12:04 +05:30
|
|
|
async def update_board(
|
2026-02-04 02:28:51 +05:30
|
|
|
payload: BoardUpdate,
|
2026-02-06 16:12:04 +05:30
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-02-08 21:16:26 +05:30
|
|
|
board: Board = Depends(get_board_for_user_write),
|
2026-02-04 02:28:51 +05:30
|
|
|
) -> Board:
|
2026-02-06 16:12:04 +05:30
|
|
|
return await _apply_board_update(payload=payload, session=session, board=board)
|
2026-02-04 02:28:51 +05:30
|
|
|
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
@router.delete("/{board_id}", response_model=OkResponse)
|
|
|
|
|
async def delete_board(
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
2026-02-08 21:16:26 +05:30
|
|
|
board: Board = Depends(get_board_for_user_write),
|
2026-02-06 16:12:04 +05:30
|
|
|
) -> OkResponse:
|
|
|
|
|
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id)))
|
|
|
|
|
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))
|
2026-02-04 16:15:01 +05:30
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
config, client_config = await _board_gateway(session, board)
|
2026-02-04 23:07:22 +05:30
|
|
|
if config and client_config:
|
2026-02-04 16:15:01 +05:30
|
|
|
try:
|
|
|
|
|
for agent in agents:
|
2026-02-06 16:12:04 +05:30
|
|
|
await _cleanup_agent_on_gateway(agent, config, client_config)
|
2026-02-04 16:15:01 +05:30
|
|
|
except OpenClawGatewayError as exc:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
|
|
|
detail=f"Gateway cleanup failed: {exc}",
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
if task_ids:
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id).in_(task_ids)))
|
2026-02-07 02:42:33 +05:30
|
|
|
await session.execute(delete(TaskDependency).where(col(TaskDependency.board_id) == board.id))
|
|
|
|
|
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.board_id) == board.id))
|
|
|
|
|
|
|
|
|
|
# Approvals can reference tasks and agents, so delete before both.
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.execute(delete(Approval).where(col(Approval.board_id) == board.id))
|
2026-02-07 02:42:33 +05:30
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.execute(delete(BoardMemory).where(col(BoardMemory.board_id) == board.id))
|
|
|
|
|
await session.execute(
|
2026-02-06 02:43:08 +05:30
|
|
|
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id)
|
2026-02-05 15:29:41 +05:30
|
|
|
)
|
2026-02-07 02:42:33 +05:30
|
|
|
|
|
|
|
|
# Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so
|
|
|
|
|
# delete tasks before agents.
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.execute(delete(Task).where(col(Task.board_id) == board.id))
|
2026-02-07 02:42:33 +05:30
|
|
|
|
|
|
|
|
if agents:
|
|
|
|
|
agent_ids = [agent.id for agent in agents]
|
|
|
|
|
await session.execute(
|
|
|
|
|
delete(ActivityEvent).where(col(ActivityEvent.agent_id).in_(agent_ids))
|
|
|
|
|
)
|
|
|
|
|
await session.execute(delete(Agent).where(col(Agent.id).in_(agent_ids)))
|
2026-02-06 16:12:04 +05:30
|
|
|
await session.delete(board)
|
|
|
|
|
await session.commit()
|
|
|
|
|
return OkResponse()
|