refactor: update module docstrings for clarity and consistency
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""API router modules for the OpenClaw Mission Control backend."""
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Agent-scoped API routes for board operations and gateway coordination."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import SQLModel, col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api import agents as agents_api
|
||||
from app.api import approvals as approvals_api
|
||||
@@ -27,11 +28,7 @@ from app.integrations.openclaw_gateway import (
|
||||
openclaw_call,
|
||||
send_message,
|
||||
)
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.approvals import Approval
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.task_dependencies import TaskDependency
|
||||
@@ -58,7 +55,13 @@ from app.schemas.gateway_coordination import (
|
||||
GatewayMainAskUserResponse,
|
||||
)
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from app.schemas.tasks import (
|
||||
TaskCommentCreate,
|
||||
TaskCommentRead,
|
||||
TaskCreate,
|
||||
TaskRead,
|
||||
TaskUpdate,
|
||||
)
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.board_leads import ensure_board_lead_agent
|
||||
from app.services.task_dependencies import (
|
||||
@@ -67,11 +70,27 @@ from app.services.task_dependencies import (
|
||||
validate_dependency_update,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.approvals import Approval
|
||||
from app.models.board_memory import BoardMemory
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
|
||||
router = APIRouter(prefix="/agent", tags=["agent"])
|
||||
|
||||
_AGENT_SESSION_PREFIX = "agent:"
|
||||
_SESSION_KEY_PARTS_MIN = 2
|
||||
_LEAD_SESSION_KEY_MISSING = "Lead agent has no session key"
|
||||
SESSION_DEP = Depends(get_session)
|
||||
AGENT_CTX_DEP = Depends(get_agent_auth_context)
|
||||
BOARD_DEP = Depends(get_board_or_404)
|
||||
TASK_DEP = Depends(get_task_or_404)
|
||||
BOARD_ID_QUERY = Query(default=None)
|
||||
TASK_STATUS_QUERY = Query(default=None, alias="status")
|
||||
IS_CHAT_QUERY = Query(default=None)
|
||||
APPROVAL_STATUS_QUERY = Query(default=None, alias="status")
|
||||
|
||||
|
||||
def _gateway_agent_id(agent: Agent) -> str:
|
||||
@@ -87,6 +106,8 @@ def _gateway_agent_id(agent: Agent) -> str:
|
||||
|
||||
|
||||
class SoulUpdateRequest(SQLModel):
|
||||
"""Payload for updating an agent SOUL document."""
|
||||
|
||||
content: str
|
||||
source_url: str | None = None
|
||||
reason: str | None = None
|
||||
@@ -124,9 +145,12 @@ async def _require_gateway_main(
|
||||
session_key = (agent.openclaw_session_id or "").strip()
|
||||
if not session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Agent missing session key"
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Agent missing session key",
|
||||
)
|
||||
gateway = await Gateway.objects.filter_by(main_session_key=session_key).first(session)
|
||||
gateway = await Gateway.objects.filter_by(main_session_key=session_key).first(
|
||||
session,
|
||||
)
|
||||
if gateway is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -148,7 +172,9 @@ async def _require_gateway_board(
|
||||
) -> Board:
|
||||
board = await Board.objects.by_id(board_id).first(session)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Board not found",
|
||||
)
|
||||
if board.gateway_id != gateway.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return board
|
||||
@@ -156,9 +182,10 @@ async def _require_gateway_board(
|
||||
|
||||
@router.get("/boards", response_model=DefaultLimitOffsetPage[BoardRead])
|
||||
async def list_boards(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[BoardRead]:
|
||||
"""List boards visible to the authenticated agent."""
|
||||
statement = select(Board)
|
||||
if agent_ctx.agent.board_id:
|
||||
statement = statement.where(col(Board.id) == agent_ctx.agent.board_id)
|
||||
@@ -168,19 +195,21 @@ async def list_boards(
|
||||
|
||||
@router.get("/boards/{board_id}", response_model=BoardRead)
|
||||
def get_board(
|
||||
board: Board = Depends(get_board_or_404),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> Board:
|
||||
"""Return a board if the authenticated agent can access it."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/agents", response_model=DefaultLimitOffsetPage[AgentRead])
|
||||
async def list_agents(
|
||||
board_id: UUID | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board_id: UUID | None = BOARD_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[AgentRead]:
|
||||
"""List agents, optionally filtered to a board."""
|
||||
statement = select(Agent)
|
||||
if agent_ctx.agent.board_id:
|
||||
if board_id and board_id != agent_ctx.agent.board_id:
|
||||
@@ -188,13 +217,19 @@ async def list_agents(
|
||||
statement = statement.where(Agent.board_id == agent_ctx.agent.board_id)
|
||||
elif board_id:
|
||||
statement = statement.where(Agent.board_id == board_id)
|
||||
main_session_keys = await agents_api._get_gateway_main_session_keys(session)
|
||||
get_gateway_main_session_keys = (
|
||||
agents_api._get_gateway_main_session_keys # noqa: SLF001
|
||||
)
|
||||
to_agent_read = agents_api._to_agent_read # noqa: SLF001
|
||||
with_computed_status = agents_api._with_computed_status # noqa: SLF001
|
||||
|
||||
main_session_keys = await get_gateway_main_session_keys(session)
|
||||
statement = statement.order_by(col(Agent.created_at).desc())
|
||||
|
||||
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
||||
agents = cast(Sequence[Agent], items)
|
||||
return [
|
||||
agents_api._to_agent_read(agents_api._with_computed_status(agent), main_session_keys)
|
||||
to_agent_read(with_computed_status(agent), main_session_keys)
|
||||
for agent in agents
|
||||
]
|
||||
|
||||
@@ -202,14 +237,15 @@ async def list_agents(
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/tasks", response_model=DefaultLimitOffsetPage[TaskRead])
|
||||
async def list_tasks(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
async def list_tasks( # noqa: PLR0913
|
||||
status_filter: str | None = TASK_STATUS_QUERY,
|
||||
assigned_agent_id: UUID | None = None,
|
||||
unassigned: bool | None = None,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[TaskRead]:
|
||||
"""List tasks on a board with optional status and assignment filters."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await tasks_api.list_tasks(
|
||||
status_filter=status_filter,
|
||||
@@ -224,10 +260,11 @@ async def list_tasks(
|
||||
@router.post("/boards/{board_id}/tasks", response_model=TaskRead)
|
||||
async def create_task(
|
||||
payload: TaskCreate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> TaskRead:
|
||||
"""Create a task on the board as the lead agent."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
@@ -250,7 +287,9 @@ async def create_task(
|
||||
board_id=board.id,
|
||||
dependency_ids=normalized_deps,
|
||||
)
|
||||
blocked_by = blocked_by_dependency_ids(dependency_ids=normalized_deps, status_by_id=dep_status)
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=normalized_deps, status_by_id=dep_status,
|
||||
)
|
||||
|
||||
if blocked_by and (task.assigned_agent_id is not None or task.status != "inbox"):
|
||||
raise HTTPException(
|
||||
@@ -280,7 +319,7 @@ async def create_task(
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
depends_on_task_id=dep_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
@@ -293,9 +332,14 @@ async def create_task(
|
||||
)
|
||||
await session.commit()
|
||||
if task.assigned_agent_id:
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
|
||||
session,
|
||||
)
|
||||
if assigned_agent:
|
||||
await tasks_api._notify_agent_on_task_assign(
|
||||
notify_agent_on_task_assign = (
|
||||
tasks_api._notify_agent_on_task_assign # noqa: SLF001
|
||||
)
|
||||
await notify_agent_on_task_assign(
|
||||
session=session,
|
||||
board=board,
|
||||
task=task,
|
||||
@@ -306,18 +350,23 @@ async def create_task(
|
||||
"depends_on_task_ids": normalized_deps,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead)
|
||||
async def update_task(
|
||||
payload: TaskUpdate,
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> TaskRead:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
"""Update a task after board-level access checks."""
|
||||
if (
|
||||
agent_ctx.agent.board_id
|
||||
and task.board_id
|
||||
and agent_ctx.agent.board_id != task.board_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return await tasks_api.update_task(
|
||||
payload=payload,
|
||||
@@ -332,11 +381,16 @@ async def update_task(
|
||||
response_model=DefaultLimitOffsetPage[TaskCommentRead],
|
||||
)
|
||||
async def list_task_comments(
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[TaskCommentRead]:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
"""List comments for a task visible to the authenticated agent."""
|
||||
if (
|
||||
agent_ctx.agent.board_id
|
||||
and task.board_id
|
||||
and agent_ctx.agent.board_id != task.board_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return await tasks_api.list_task_comments(
|
||||
task=task,
|
||||
@@ -344,14 +398,21 @@ async def list_task_comments(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead)
|
||||
@router.post(
|
||||
"/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead,
|
||||
)
|
||||
async def create_task_comment(
|
||||
payload: TaskCommentCreate,
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> ActivityEvent:
|
||||
if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id:
|
||||
"""Create a task comment on behalf of the authenticated agent."""
|
||||
if (
|
||||
agent_ctx.agent.board_id
|
||||
and task.board_id
|
||||
and agent_ctx.agent.board_id != task.board_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return await tasks_api.create_task_comment(
|
||||
payload=payload,
|
||||
@@ -361,13 +422,16 @@ async def create_task_comment(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/memory", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
|
||||
@router.get(
|
||||
"/boards/{board_id}/memory", response_model=DefaultLimitOffsetPage[BoardMemoryRead],
|
||||
)
|
||||
async def list_board_memory(
|
||||
is_chat: bool | None = Query(default=None),
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
is_chat: bool | None = IS_CHAT_QUERY,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
|
||||
"""List board memory entries with optional chat filtering."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await board_memory_api.list_board_memory(
|
||||
is_chat=is_chat,
|
||||
@@ -380,10 +444,11 @@ async def list_board_memory(
|
||||
@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead)
|
||||
async def create_board_memory(
|
||||
payload: BoardMemoryCreate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> BoardMemory:
|
||||
"""Create a board memory entry."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await board_memory_api.create_board_memory(
|
||||
payload=payload,
|
||||
@@ -398,11 +463,12 @@ async def create_board_memory(
|
||||
response_model=DefaultLimitOffsetPage[ApprovalRead],
|
||||
)
|
||||
async def list_approvals(
|
||||
status_filter: ApprovalStatus | None = Query(default=None, alias="status"),
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
status_filter: ApprovalStatus | None = APPROVAL_STATUS_QUERY,
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> DefaultLimitOffsetPage[ApprovalRead]:
|
||||
"""List approvals for a board."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await approvals_api.list_approvals(
|
||||
status_filter=status_filter,
|
||||
@@ -415,10 +481,11 @@ async def list_approvals(
|
||||
@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead)
|
||||
async def create_approval(
|
||||
payload: ApprovalCreate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> Approval:
|
||||
"""Create a board approval request."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await approvals_api.create_approval(
|
||||
payload=payload,
|
||||
@@ -431,10 +498,11 @@ async def create_approval(
|
||||
@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead)
|
||||
async def update_onboarding(
|
||||
payload: BoardOnboardingAgentUpdate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> BoardOnboardingSession:
|
||||
"""Apply onboarding updates for a board."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
return await onboarding_api.agent_onboarding_update(
|
||||
payload=payload,
|
||||
@@ -447,14 +515,17 @@ async def update_onboarding(
|
||||
@router.post("/agents", response_model=AgentRead)
|
||||
async def create_agent(
|
||||
payload: AgentCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> AgentRead:
|
||||
"""Create an agent on the caller's board."""
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if not agent_ctx.agent.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id})
|
||||
payload = AgentCreate(
|
||||
**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id},
|
||||
)
|
||||
return await agents_api.create_agent(
|
||||
payload=payload,
|
||||
session=session,
|
||||
@@ -466,10 +537,11 @@ async def create_agent(
|
||||
async def nudge_agent(
|
||||
payload: AgentNudge,
|
||||
agent_id: str,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> OkResponse:
|
||||
"""Send a direct nudge message to a board agent."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
@@ -484,7 +556,9 @@ async def nudge_agent(
|
||||
message = payload.message
|
||||
config = await _gateway_config(session, board)
|
||||
try:
|
||||
await ensure_session(target.openclaw_session_id, config=config, label=target.name)
|
||||
await ensure_session(
|
||||
target.openclaw_session_id, config=config, label=target.name,
|
||||
)
|
||||
await send_message(
|
||||
message,
|
||||
session_key=target.openclaw_session_id,
|
||||
@@ -499,7 +573,9 @@ async def nudge_agent(
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.nudge.sent",
|
||||
@@ -513,9 +589,10 @@ async def nudge_agent(
|
||||
@router.post("/heartbeat", response_model=AgentRead)
|
||||
async def agent_heartbeat(
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> AgentRead:
|
||||
"""Record heartbeat status for the authenticated agent."""
|
||||
# Heartbeats must apply to the authenticated agent; agent names are not unique.
|
||||
return await agents_api.heartbeat_agent(
|
||||
agent_id=str(agent_ctx.agent.id),
|
||||
@@ -528,10 +605,11 @@ async def agent_heartbeat(
|
||||
@router.get("/boards/{board_id}/agents/{agent_id}/soul", response_model=str)
|
||||
async def get_agent_soul(
|
||||
agent_id: str,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> str:
|
||||
"""Fetch the target agent's SOUL.md content from the gateway."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead and str(agent_ctx.agent.id) != agent_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
@@ -547,7 +625,9 @@ async def get_agent_soul(
|
||||
config=config,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
if isinstance(payload, str):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
@@ -559,17 +639,20 @@ async def get_agent_soul(
|
||||
nested = file_obj.get("content")
|
||||
if isinstance(nested, str):
|
||||
return nested
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid gateway response")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail="Invalid gateway response",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/boards/{board_id}/agents/{agent_id}/soul", response_model=OkResponse)
|
||||
async def update_agent_soul(
|
||||
agent_id: str,
|
||||
payload: SoulUpdateRequest,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> OkResponse:
|
||||
"""Update an agent's SOUL.md content in DB and gateway."""
|
||||
_guard_board_access(agent_ctx, board)
|
||||
if not agent_ctx.agent.is_board_lead:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
@@ -597,7 +680,9 @@ async def update_agent_soul(
|
||||
config=config,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
reason = (payload.reason or "").strip()
|
||||
source_url = (payload.source_url or "").strip()
|
||||
note = f"SOUL.md updated for {target.name}."
|
||||
@@ -621,10 +706,11 @@ async def update_agent_soul(
|
||||
)
|
||||
async def ask_user_via_gateway_main(
|
||||
payload: GatewayMainAskUserRequest,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
board: Board = BOARD_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> GatewayMainAskUserResponse:
|
||||
"""Route a lead's ask-user request through the gateway main agent."""
|
||||
import json
|
||||
|
||||
_guard_board_access(agent_ctx, board)
|
||||
@@ -653,7 +739,9 @@ async def ask_user_via_gateway_main(
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
preferred_channel = (payload.preferred_channel or "").strip()
|
||||
channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
|
||||
channel_line = (
|
||||
f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
|
||||
)
|
||||
|
||||
tags = payload.reply_tags or ["gateway_main", "user_reply"]
|
||||
tags_json = json.dumps(tags)
|
||||
@@ -668,9 +756,12 @@ async def ask_user_via_gateway_main(
|
||||
f"{correlation_line}"
|
||||
f"{channel_line}\n"
|
||||
f"{payload.content.strip()}\n\n"
|
||||
"Please reach the user via your configured OpenClaw channel(s) (Slack/SMS/etc).\n"
|
||||
"If you cannot reach them there, post the question in Mission Control board chat as a fallback.\n\n"
|
||||
"When you receive the answer, reply in Mission Control by writing a NON-chat memory item on this board:\n"
|
||||
"Please reach the user via your configured OpenClaw channel(s) "
|
||||
"(Slack/SMS/etc).\n"
|
||||
"If you cannot reach them there, post the question in Mission Control "
|
||||
"board chat as a fallback.\n\n"
|
||||
"When you receive the answer, reply in Mission Control by writing a "
|
||||
"NON-chat memory item on this board:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
f'Body: {{"content":"<answer>","tags":{tags_json},"source":"{reply_source}"}}\n'
|
||||
"Do NOT reply in OpenClaw chat."
|
||||
@@ -678,7 +769,9 @@ async def ask_user_via_gateway_main(
|
||||
|
||||
try:
|
||||
await ensure_session(main_session_key, config=config, label="Main Agent")
|
||||
await send_message(message, session_key=main_session_key, config=config, deliver=True)
|
||||
await send_message(
|
||||
message, session_key=main_session_key, config=config, deliver=True,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
record_activity(
|
||||
session,
|
||||
@@ -687,7 +780,9 @@ async def ask_user_via_gateway_main(
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
@@ -696,7 +791,9 @@ async def ask_user_via_gateway_main(
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
|
||||
main_agent = await Agent.objects.filter_by(openclaw_session_id=main_session_key).first(session)
|
||||
main_agent = await Agent.objects.filter_by(
|
||||
openclaw_session_id=main_session_key,
|
||||
).first(session)
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -714,9 +811,10 @@ async def ask_user_via_gateway_main(
|
||||
async def message_gateway_board_lead(
|
||||
board_id: UUID,
|
||||
payload: GatewayLeadMessageRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> GatewayLeadMessageResponse:
|
||||
"""Send a gateway-main message to a single board lead agent."""
|
||||
import json
|
||||
|
||||
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
|
||||
@@ -736,7 +834,11 @@ async def message_gateway_board_lead(
|
||||
)
|
||||
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
|
||||
header = (
|
||||
"GATEWAY MAIN QUESTION"
|
||||
if payload.kind == "question"
|
||||
else "GATEWAY MAIN HANDOFF"
|
||||
)
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
|
||||
@@ -767,7 +869,9 @@ async def message_gateway_board_lead(
|
||||
agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
|
||||
record_activity(
|
||||
session,
|
||||
@@ -791,9 +895,10 @@ async def message_gateway_board_lead(
|
||||
)
|
||||
async def broadcast_gateway_lead_message(
|
||||
payload: GatewayLeadBroadcastRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> GatewayLeadBroadcastResponse:
|
||||
"""Broadcast a gateway-main message to multiple board leads."""
|
||||
import json
|
||||
|
||||
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
|
||||
@@ -808,7 +913,11 @@ async def broadcast_gateway_lead_message(
|
||||
boards = list(await session.exec(statement))
|
||||
|
||||
base_url = settings.base_url or "http://localhost:8000"
|
||||
header = "GATEWAY MAIN QUESTION" if payload.kind == "question" else "GATEWAY MAIN HANDOFF"
|
||||
header = (
|
||||
"GATEWAY MAIN QUESTION"
|
||||
if payload.kind == "question"
|
||||
else "GATEWAY MAIN HANDOFF"
|
||||
)
|
||||
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
|
||||
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
|
||||
tags = payload.reply_tags or ["gateway_main", "lead_reply"]
|
||||
@@ -819,7 +928,7 @@ async def broadcast_gateway_lead_message(
|
||||
sent = 0
|
||||
failed = 0
|
||||
|
||||
for board in boards:
|
||||
async def _send_to_board(board: Board) -> GatewayLeadBroadcastBoardResult:
|
||||
try:
|
||||
lead, _lead_created = await ensure_board_lead_agent(
|
||||
session,
|
||||
@@ -837,30 +946,34 @@ async def broadcast_gateway_lead_message(
|
||||
f"From agent: {agent_ctx.agent.name}\n"
|
||||
f"{correlation_line}\n"
|
||||
f"{payload.content.strip()}\n\n"
|
||||
"Reply to the gateway main by writing a NON-chat memory item on this board:\n"
|
||||
"Reply to the gateway main by writing a NON-chat memory item "
|
||||
"on this board:\n"
|
||||
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
|
||||
f'Body: {{"content":"...","tags":{tags_json},"source":"{reply_source}"}}\n'
|
||||
f'Body: {{"content":"...","tags":{tags_json},'
|
||||
f'"source":"{reply_source}"}}\n'
|
||||
"Do NOT reply in OpenClaw chat."
|
||||
)
|
||||
await ensure_session(lead_session_key, config=config, label=lead.name)
|
||||
await send_message(message, session_key=lead_session_key, config=config)
|
||||
results.append(
|
||||
GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
lead_agent_id=lead.id,
|
||||
lead_agent_name=lead.name,
|
||||
ok=True,
|
||||
)
|
||||
return GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
lead_agent_id=lead.id,
|
||||
lead_agent_name=lead.name,
|
||||
ok=True,
|
||||
)
|
||||
sent += 1
|
||||
except (HTTPException, OpenClawGatewayError, ValueError) as exc:
|
||||
results.append(
|
||||
GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
ok=False,
|
||||
error=str(exc),
|
||||
)
|
||||
return GatewayLeadBroadcastBoardResult(
|
||||
board_id=board.id,
|
||||
ok=False,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
for board in boards:
|
||||
board_result = await _send_to_board(board)
|
||||
results.append(board_result)
|
||||
if board_result.ok:
|
||||
sent += 1
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
record_activity(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Agent lifecycle, listing, heartbeat, and deletion API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -5,14 +7,12 @@ import json
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import asc, or_
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin
|
||||
@@ -23,14 +23,17 @@ from app.db import crud
|
||||
from app.db.pagination import paginate
|
||||
from app.db.session import async_session_maker, get_session
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.models.organizations import Organization
|
||||
from app.models.tasks import Task
|
||||
from app.models.users import User
|
||||
from app.schemas.agents import (
|
||||
AgentCreate,
|
||||
AgentHeartbeat,
|
||||
@@ -56,10 +59,23 @@ from app.services.organizations import (
|
||||
require_board_access,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.users import User
|
||||
|
||||
router = APIRouter(prefix="/agents", tags=["agents"])
|
||||
|
||||
OFFLINE_AFTER = timedelta(minutes=10)
|
||||
AGENT_SESSION_PREFIX = "agent"
|
||||
BOARD_ID_QUERY = Query(default=None)
|
||||
GATEWAY_ID_QUERY = Query(default=None)
|
||||
SINCE_QUERY = Query(default=None)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
||||
AUTH_DEP = Depends(get_auth_context)
|
||||
|
||||
|
||||
def _parse_since(value: str | None) -> datetime | None:
|
||||
@@ -111,14 +127,16 @@ async def _require_board(
|
||||
)
|
||||
board = await Board.objects.by_id(board_id).first(session)
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Board not found",
|
||||
)
|
||||
if user is not None:
|
||||
await require_board_access(session, user=user, board=board, write=write)
|
||||
return board
|
||||
|
||||
|
||||
async def _require_gateway(
|
||||
session: AsyncSession, board: Board
|
||||
session: AsyncSession, board: Board,
|
||||
) -> tuple[Gateway, GatewayClientConfig]:
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(
|
||||
@@ -169,16 +187,20 @@ async def _get_gateway_main_session_keys(session: AsyncSession) -> set[str]:
|
||||
|
||||
|
||||
def _is_gateway_main(agent: Agent, main_session_keys: set[str]) -> bool:
|
||||
return bool(agent.openclaw_session_id and agent.openclaw_session_id in main_session_keys)
|
||||
return bool(
|
||||
agent.openclaw_session_id and agent.openclaw_session_id in main_session_keys,
|
||||
)
|
||||
|
||||
|
||||
def _to_agent_read(agent: Agent, main_session_keys: set[str]) -> AgentRead:
|
||||
model = AgentRead.model_validate(agent, from_attributes=True)
|
||||
return model.model_copy(update={"is_gateway_main": _is_gateway_main(agent, main_session_keys)})
|
||||
return model.model_copy(
|
||||
update={"is_gateway_main": _is_gateway_main(agent, main_session_keys)},
|
||||
)
|
||||
|
||||
|
||||
async def _find_gateway_for_main_session(
|
||||
session: AsyncSession, session_key: str | None
|
||||
session: AsyncSession, session_key: str | None,
|
||||
) -> Gateway | None:
|
||||
if not session_key:
|
||||
return None
|
||||
@@ -210,7 +232,9 @@ def _with_computed_status(agent: Agent) -> Agent:
|
||||
|
||||
|
||||
def _serialize_agent(agent: Agent, main_session_keys: set[str]) -> dict[str, object]:
|
||||
return _to_agent_read(_with_computed_status(agent), main_session_keys).model_dump(mode="json")
|
||||
return _to_agent_read(_with_computed_status(agent), main_session_keys).model_dump(
|
||||
mode="json",
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_agent_events(
|
||||
@@ -225,18 +249,22 @@ async def _fetch_agent_events(
|
||||
or_(
|
||||
col(Agent.updated_at) >= since,
|
||||
col(Agent.last_seen_at) >= since,
|
||||
)
|
||||
),
|
||||
).order_by(asc(col(Agent.updated_at)))
|
||||
return list(await session.exec(statement))
|
||||
|
||||
|
||||
async def _require_user_context(session: AsyncSession, user: User | None) -> OrganizationContext:
|
||||
async def _require_user_context(
|
||||
session: AsyncSession, user: User | None,
|
||||
) -> OrganizationContext:
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
member = await get_active_membership(session, user)
|
||||
if member is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(session)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(
|
||||
session,
|
||||
)
|
||||
if organization is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
return OrganizationContext(organization=organization, member=member)
|
||||
@@ -252,7 +280,9 @@ async def _require_agent_access(
|
||||
if agent.board_id is None:
|
||||
if not is_org_admin(ctx.member):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id)
|
||||
gateway = await _find_gateway_for_main_session(
|
||||
session, agent.openclaw_session_id,
|
||||
)
|
||||
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return
|
||||
@@ -274,7 +304,7 @@ def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
|
||||
|
||||
|
||||
def _record_instruction_failure(
|
||||
session: AsyncSession, agent: Agent, error: str, action: str
|
||||
session: AsyncSession, agent: Agent, error: str, action: str,
|
||||
) -> None:
|
||||
action_label = action.replace("_", " ").capitalize()
|
||||
record_activity(
|
||||
@@ -286,7 +316,7 @@ def _record_instruction_failure(
|
||||
|
||||
|
||||
async def _send_wakeup_message(
|
||||
agent: Agent, config: GatewayClientConfig, verb: str = "provisioned"
|
||||
agent: Agent, config: GatewayClientConfig, verb: str = "provisioned",
|
||||
) -> None:
|
||||
session_key = agent.openclaw_session_id or _build_session_key(agent.name)
|
||||
await ensure_session(session_key, config=config, label=agent.name)
|
||||
@@ -300,11 +330,12 @@ async def _send_wakeup_message(
|
||||
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[AgentRead])
|
||||
async def list_agents(
|
||||
board_id: UUID | None = Query(default=None),
|
||||
gateway_id: UUID | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
board_id: UUID | None = BOARD_ID_QUERY,
|
||||
gateway_id: UUID | None = GATEWAY_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> DefaultLimitOffsetPage[AgentRead]:
|
||||
"""List agents visible to the active organization admin."""
|
||||
main_session_keys = await _get_gateway_main_session_keys(session)
|
||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||
if board_id is not None and board_id not in set(board_ids):
|
||||
@@ -315,9 +346,11 @@ async def list_agents(
|
||||
base_filter: ColumnElement[bool] = col(Agent.board_id).in_(board_ids)
|
||||
if is_org_admin(ctx.member):
|
||||
gateway_keys = select(Gateway.main_session_key).where(
|
||||
col(Gateway.organization_id) == ctx.organization.id
|
||||
col(Gateway.organization_id) == ctx.organization.id,
|
||||
)
|
||||
base_filter = or_(
|
||||
base_filter, col(Agent.openclaw_session_id).in_(gateway_keys),
|
||||
)
|
||||
base_filter = or_(base_filter, col(Agent.openclaw_session_id).in_(gateway_keys))
|
||||
statement = select(Agent).where(base_filter)
|
||||
if board_id is not None:
|
||||
statement = statement.where(col(Agent.board_id) == board_id)
|
||||
@@ -326,13 +359,16 @@ async def list_agents(
|
||||
if gateway is None or gateway.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
|
||||
col(Board.gateway_id) == gateway_id
|
||||
col(Board.gateway_id) == gateway_id,
|
||||
)
|
||||
statement = statement.order_by(col(Agent.created_at).desc())
|
||||
|
||||
def _transform(items: Sequence[Any]) -> Sequence[Any]:
|
||||
agents = cast(Sequence[Agent], items)
|
||||
return [_to_agent_read(_with_computed_status(agent), main_session_keys) for agent in agents]
|
||||
return [
|
||||
_to_agent_read(_with_computed_status(agent), main_session_keys)
|
||||
for agent in agents
|
||||
]
|
||||
|
||||
return await paginate(session, statement, transformer=_transform)
|
||||
|
||||
@@ -340,11 +376,12 @@ async def list_agents(
|
||||
@router.get("/stream")
|
||||
async def stream_agents(
|
||||
request: Request,
|
||||
board_id: UUID | None = Query(default=None),
|
||||
since: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
board_id: UUID | None = BOARD_ID_QUERY,
|
||||
since: str | None = SINCE_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> EventSourceResponse:
|
||||
"""Stream agent updates as SSE events."""
|
||||
since_dt = _parse_since(since) or utcnow()
|
||||
last_seen = since_dt
|
||||
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
|
||||
@@ -359,14 +396,20 @@ async def stream_agents(
|
||||
break
|
||||
async with async_session_maker() as stream_session:
|
||||
if board_id is not None:
|
||||
agents = await _fetch_agent_events(stream_session, board_id, last_seen)
|
||||
agents = await _fetch_agent_events(
|
||||
stream_session, board_id, last_seen,
|
||||
)
|
||||
elif allowed_ids:
|
||||
agents = await _fetch_agent_events(stream_session, None, last_seen)
|
||||
agents = [agent for agent in agents if agent.board_id in allowed_ids]
|
||||
agents = [
|
||||
agent for agent in agents if agent.board_id in allowed_ids
|
||||
]
|
||||
else:
|
||||
agents = []
|
||||
main_session_keys = (
|
||||
await _get_gateway_main_session_keys(stream_session) if agents else set()
|
||||
await _get_gateway_main_session_keys(stream_session)
|
||||
if agents
|
||||
else set()
|
||||
)
|
||||
for agent in agents:
|
||||
updated_at = agent.updated_at or agent.last_seen_at or utcnow()
|
||||
@@ -379,11 +422,12 @@ async def stream_agents(
|
||||
|
||||
|
||||
@router.post("", response_model=AgentRead)
|
||||
async def create_agent(
|
||||
async def create_agent( # noqa: C901, PLR0912, PLR0915
|
||||
payload: AgentCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> AgentRead:
|
||||
"""Create and provision an agent."""
|
||||
if actor.actor_type == "user":
|
||||
ctx = await _require_user_context(session, actor.user)
|
||||
if not is_org_admin(ctx.member):
|
||||
@@ -404,7 +448,9 @@ async def create_agent(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads can only create agents in their own board",
|
||||
)
|
||||
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
|
||||
payload = AgentCreate(
|
||||
**{**payload.model_dump(), "board_id": actor.agent.board_id},
|
||||
)
|
||||
|
||||
board = await _require_board(
|
||||
session,
|
||||
@@ -420,7 +466,7 @@ async def create_agent(
|
||||
await session.exec(
|
||||
select(Agent)
|
||||
.where(Agent.board_id == board.id)
|
||||
.where(col(Agent.name).ilike(requested_name))
|
||||
.where(col(Agent.name).ilike(requested_name)),
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
@@ -428,20 +474,23 @@ async def create_agent(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An agent with this name already exists on this board.",
|
||||
)
|
||||
# Prevent OpenClaw session/workspace collisions by enforcing uniqueness within
|
||||
# the gateway workspace too (agents on other boards share the same gateway root).
|
||||
# Prevent session/workspace collisions inside the gateway workspace.
|
||||
# Agents on different boards can still share one gateway root.
|
||||
existing_gateway = (
|
||||
await session.exec(
|
||||
select(Agent)
|
||||
.join(Board, col(Agent.board_id) == col(Board.id))
|
||||
.where(col(Board.gateway_id) == gateway.id)
|
||||
.where(col(Agent.name).ilike(requested_name))
|
||||
.where(col(Agent.name).ilike(requested_name)),
|
||||
)
|
||||
).first()
|
||||
if existing_gateway:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An agent with this name already exists in this gateway workspace.",
|
||||
detail=(
|
||||
"An agent with this name already exists in this gateway "
|
||||
"workspace."
|
||||
),
|
||||
)
|
||||
desired_session_key = _build_session_key(requested_name)
|
||||
existing_session_key = (
|
||||
@@ -449,13 +498,16 @@ async def create_agent(
|
||||
select(Agent)
|
||||
.join(Board, col(Agent.board_id) == col(Board.id))
|
||||
.where(col(Board.gateway_id) == gateway.id)
|
||||
.where(col(Agent.openclaw_session_id) == desired_session_key)
|
||||
.where(col(Agent.openclaw_session_id) == desired_session_key),
|
||||
)
|
||||
).first()
|
||||
if existing_session_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This agent name would collide with an existing workspace session key. Pick a different name.",
|
||||
detail=(
|
||||
"This agent name would collide with an existing workspace "
|
||||
"session key. Pick a different name."
|
||||
),
|
||||
)
|
||||
agent = Agent.model_validate(data)
|
||||
agent.status = "provisioning"
|
||||
@@ -465,7 +517,9 @@ async def create_agent(
|
||||
agent.heartbeat_config = DEFAULT_HEARTBEAT_CONFIG.copy()
|
||||
agent.provision_requested_at = utcnow()
|
||||
agent.provision_action = "provision"
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config,
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
@@ -527,9 +581,10 @@ async def create_agent(
|
||||
@router.get("/{agent_id}", response_model=AgentRead)
|
||||
async def get_agent(
|
||||
agent_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> AgentRead:
|
||||
"""Get a single agent by id."""
|
||||
agent = await Agent.objects.by_id(agent_id).first(session)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -539,14 +594,16 @@ async def get_agent(
|
||||
|
||||
|
||||
@router.patch("/{agent_id}", response_model=AgentRead)
|
||||
async def update_agent(
|
||||
async def update_agent( # noqa: C901, PLR0912, PLR0913, PLR0915
|
||||
agent_id: str,
|
||||
payload: AgentUpdate,
|
||||
*,
|
||||
force: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> AgentRead:
|
||||
"""Update agent metadata and optionally reprovision."""
|
||||
agent = await Agent.objects.by_id(agent_id).first(session)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -564,12 +621,16 @@ async def update_agent(
|
||||
new_board = await _require_board(session, updates["board_id"])
|
||||
if new_board.organization_id != ctx.organization.id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if not await has_board_access(session, member=ctx.member, board=new_board, write=True):
|
||||
if not await has_board_access(
|
||||
session, member=ctx.member, board=new_board, write=True,
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if not updates and not force and make_main is None:
|
||||
main_session_keys = await _get_gateway_main_session_keys(session)
|
||||
return _to_agent_read(_with_computed_status(agent), main_session_keys)
|
||||
main_gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id)
|
||||
main_gateway = await _find_gateway_for_main_session(
|
||||
session, agent.openclaw_session_id,
|
||||
)
|
||||
gateway_for_main: Gateway | None = None
|
||||
if make_main is True:
|
||||
board_source = updates.get("board_id") or agent.board_id
|
||||
@@ -723,9 +784,10 @@ async def update_agent(
|
||||
async def heartbeat_agent(
|
||||
agent_id: str,
|
||||
payload: AgentHeartbeat,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> AgentRead:
|
||||
"""Record a heartbeat for a specific agent."""
|
||||
agent = await Agent.objects.by_id(agent_id).first(session)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
@@ -751,12 +813,14 @@ async def heartbeat_agent(
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=AgentRead)
|
||||
async def heartbeat_or_create_agent(
|
||||
async def heartbeat_or_create_agent( # noqa: C901, PLR0912, PLR0915
|
||||
payload: AgentHeartbeatCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> AgentRead:
|
||||
# Agent tokens must heartbeat their authenticated agent record. Names are not unique.
|
||||
"""Heartbeat an existing agent or create/provision one if needed."""
|
||||
# Agent tokens must heartbeat their authenticated agent record.
|
||||
# Names are not unique.
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
return await heartbeat_agent(
|
||||
agent_id=str(actor.agent.id),
|
||||
@@ -793,7 +857,9 @@ async def heartbeat_or_create_agent(
|
||||
agent.agent_token_hash = hash_agent_token(raw_token)
|
||||
agent.provision_requested_at = utcnow()
|
||||
agent.provision_action = "provision"
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config,
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
@@ -814,7 +880,9 @@ async def heartbeat_or_create_agent(
|
||||
)
|
||||
await session.commit()
|
||||
try:
|
||||
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
|
||||
await provision_agent(
|
||||
agent, board, gateway, raw_token, actor.user, action="provision",
|
||||
)
|
||||
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||
agent.provision_confirm_token_hash = None
|
||||
agent.provision_requested_at = None
|
||||
@@ -864,7 +932,7 @@ async def heartbeat_or_create_agent(
|
||||
)
|
||||
gateway, client_config = await _require_gateway(session, board)
|
||||
await provision_agent(
|
||||
agent, board, gateway, raw_token, actor.user, action="provision"
|
||||
agent, board, gateway, raw_token, actor.user, action="provision",
|
||||
)
|
||||
await _send_wakeup_message(agent, client_config, verb="provisioned")
|
||||
agent.provision_confirm_token_hash = None
|
||||
@@ -903,7 +971,9 @@ async def heartbeat_or_create_agent(
|
||||
write=actor.actor_type == "user",
|
||||
)
|
||||
gateway, client_config = await _require_gateway(session, board)
|
||||
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
|
||||
session_key, session_error = await _ensure_gateway_session(
|
||||
agent.name, client_config,
|
||||
)
|
||||
agent.openclaw_session_id = session_key
|
||||
if session_error:
|
||||
record_activity(
|
||||
@@ -937,15 +1007,18 @@ async def heartbeat_or_create_agent(
|
||||
@router.delete("/{agent_id}", response_model=OkResponse)
|
||||
async def delete_agent(
|
||||
agent_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete an agent and clean related task state."""
|
||||
agent = await Agent.objects.by_id(agent_id).first(session)
|
||||
if agent is None:
|
||||
return OkResponse()
|
||||
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
|
||||
|
||||
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
board = await _require_board(
|
||||
session, str(agent.board_id) if agent.board_id else None,
|
||||
)
|
||||
gateway, client_config = await _require_gateway(session, board)
|
||||
try:
|
||||
workspace_path = await cleanup_agent(agent, gateway)
|
||||
@@ -970,7 +1043,7 @@ async def delete_agent(
|
||||
message=f"Deleted agent {agent.name}.",
|
||||
agent_id=None,
|
||||
)
|
||||
now = datetime.now()
|
||||
now = utcnow()
|
||||
await crud.update_where(
|
||||
session,
|
||||
Task,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Authentication bootstrap endpoints for the Mission Control API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -6,10 +8,12 @@ from app.core.auth import AuthContext, get_auth_context
|
||||
from app.schemas.users import UserRead
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
||||
|
||||
|
||||
@router.post("/bootstrap", response_model=UserRead)
|
||||
async def bootstrap_user(auth: AuthContext = Depends(get_auth_context)) -> UserRead:
|
||||
async def bootstrap_user(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
||||
"""Return the authenticated user profile from token claims."""
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return UserRead.model_validate(auth.user)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""Board onboarding endpoints for user/agent collaboration."""
|
||||
# ruff: noqa: E501
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
from sqlmodel import col
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import (
|
||||
ActorContext,
|
||||
@@ -18,15 +21,17 @@ from app.api.deps import (
|
||||
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.core.time import utcnow
|
||||
from app.db.session import get_session
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.agents import Agent
|
||||
from app.models.board_onboarding import BoardOnboardingSession
|
||||
from app.models.boards import Board
|
||||
from app.models.gateways import Gateway
|
||||
from app.schemas.board_onboarding import (
|
||||
BoardOnboardingAgentComplete,
|
||||
@@ -41,12 +46,24 @@ from app.schemas.board_onboarding import (
|
||||
from app.schemas.boards import BoardRead
|
||||
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
from app.models.boards import Board
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
|
||||
logger = logging.getLogger(__name__)
|
||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
||||
BOARD_OR_404_DEP = Depends(get_board_or_404)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
||||
ADMIN_AUTH_DEP = Depends(require_admin_auth)
|
||||
|
||||
|
||||
async def _gateway_config(
|
||||
session: AsyncSession, board: Board
|
||||
session: AsyncSession, board: Board,
|
||||
) -> tuple[Gateway, GatewayClientConfig]:
|
||||
if not board.gateway_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
@@ -61,7 +78,7 @@ def _build_session_key(agent_name: str) -> str:
|
||||
return f"agent:{slug or uuid4().hex}:main"
|
||||
|
||||
|
||||
def _lead_agent_name(board: Board) -> str:
|
||||
def _lead_agent_name(_board: Board) -> str:
|
||||
return "Lead Agent"
|
||||
|
||||
|
||||
@@ -69,7 +86,7 @@ def _lead_session_key(board: Board) -> str:
|
||||
return f"agent:lead-{board.id}:main"
|
||||
|
||||
|
||||
async def _ensure_lead_agent(
|
||||
async def _ensure_lead_agent( # noqa: PLR0913
|
||||
session: AsyncSession,
|
||||
board: Board,
|
||||
gateway: Gateway,
|
||||
@@ -100,7 +117,11 @@ async def _ensure_lead_agent(
|
||||
}
|
||||
if identity_profile:
|
||||
merged_identity_profile.update(
|
||||
{key: value.strip() for key, value in identity_profile.items() if value.strip()}
|
||||
{
|
||||
key: value.strip()
|
||||
for key, value in identity_profile.items()
|
||||
if value.strip()
|
||||
},
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
@@ -121,7 +142,9 @@ async def _ensure_lead_agent(
|
||||
await session.refresh(agent)
|
||||
|
||||
try:
|
||||
await provision_agent(agent, board, gateway, raw_token, auth.user, action="provision")
|
||||
await provision_agent(
|
||||
agent, board, gateway, raw_token, auth.user, action="provision",
|
||||
)
|
||||
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
|
||||
await send_message(
|
||||
(
|
||||
@@ -141,9 +164,10 @@ async def _ensure_lead_agent(
|
||||
|
||||
@router.get("", response_model=BoardOnboardingRead)
|
||||
async def get_onboarding(
|
||||
board: Board = Depends(get_board_for_user_read),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardOnboardingSession:
|
||||
"""Get the latest onboarding session for a board."""
|
||||
onboarding = (
|
||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
||||
@@ -156,10 +180,11 @@ async def get_onboarding(
|
||||
|
||||
@router.post("/start", response_model=BoardOnboardingRead)
|
||||
async def start_onboarding(
|
||||
payload: BoardOnboardingStart,
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_payload: BoardOnboardingStart,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardOnboardingSession:
|
||||
"""Start onboarding and send instructions to the gateway main agent."""
|
||||
onboarding = (
|
||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
||||
.filter(col(BoardOnboardingSession.status) == "active")
|
||||
@@ -219,15 +244,21 @@ async def start_onboarding(
|
||||
|
||||
try:
|
||||
await ensure_session(session_key, config=config, label="Main Agent")
|
||||
await send_message(prompt, session_key=session_key, config=config, deliver=False)
|
||||
await send_message(
|
||||
prompt, session_key=session_key, config=config, deliver=False,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
|
||||
onboarding = BoardOnboardingSession(
|
||||
board_id=board.id,
|
||||
session_key=session_key,
|
||||
status="active",
|
||||
messages=[{"role": "user", "content": prompt, "timestamp": utcnow().isoformat()}],
|
||||
messages=[
|
||||
{"role": "user", "content": prompt, "timestamp": utcnow().isoformat()},
|
||||
],
|
||||
)
|
||||
session.add(onboarding)
|
||||
await session.commit()
|
||||
@@ -238,9 +269,10 @@ async def start_onboarding(
|
||||
@router.post("/answer", response_model=BoardOnboardingRead)
|
||||
async def answer_onboarding(
|
||||
payload: BoardOnboardingAnswer,
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardOnboardingSession:
|
||||
"""Send a user onboarding answer to the gateway main agent."""
|
||||
onboarding = (
|
||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
||||
@@ -255,15 +287,22 @@ async def answer_onboarding(
|
||||
answer_text = f"{payload.answer}: {payload.other_text}"
|
||||
|
||||
messages = list(onboarding.messages or [])
|
||||
messages.append({"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()})
|
||||
messages.append(
|
||||
{"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()},
|
||||
)
|
||||
|
||||
try:
|
||||
await ensure_session(onboarding.session_key, config=config, label="Main Agent")
|
||||
await send_message(
|
||||
answer_text, session_key=onboarding.session_key, config=config, deliver=False
|
||||
answer_text,
|
||||
session_key=onboarding.session_key,
|
||||
config=config,
|
||||
deliver=False,
|
||||
)
|
||||
except OpenClawGatewayError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
|
||||
) from exc
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = utcnow()
|
||||
@@ -276,10 +315,11 @@ async def answer_onboarding(
|
||||
@router.post("/agent", response_model=BoardOnboardingRead)
|
||||
async def agent_onboarding_update(
|
||||
payload: BoardOnboardingAgentUpdate,
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
board: Board = BOARD_OR_404_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> BoardOnboardingSession:
|
||||
"""Store onboarding updates submitted by the gateway main agent."""
|
||||
if actor.actor_type != "agent" or actor.agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
agent = actor.agent
|
||||
@@ -288,9 +328,13 @@ async def agent_onboarding_update(
|
||||
|
||||
if board.gateway_id:
|
||||
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
|
||||
if gateway and gateway.main_session_key and agent.openclaw_session_id:
|
||||
if agent.openclaw_session_id != gateway.main_session_key:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if (
|
||||
gateway
|
||||
and gateway.main_session_key
|
||||
and agent.openclaw_session_id
|
||||
and agent.openclaw_session_id != gateway.main_session_key
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
onboarding = (
|
||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
||||
@@ -315,9 +359,13 @@ async def agent_onboarding_update(
|
||||
if isinstance(payload, BoardOnboardingAgentComplete):
|
||||
onboarding.draft_goal = payload_data
|
||||
onboarding.status = "completed"
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
messages.append(
|
||||
{"role": "assistant", "content": payload_text, "timestamp": now},
|
||||
)
|
||||
else:
|
||||
messages.append({"role": "assistant", "content": payload_text, "timestamp": now})
|
||||
messages.append(
|
||||
{"role": "assistant", "content": payload_text, "timestamp": now},
|
||||
)
|
||||
|
||||
onboarding.messages = messages
|
||||
onboarding.updated_at = utcnow()
|
||||
@@ -334,12 +382,13 @@ async def agent_onboarding_update(
|
||||
|
||||
|
||||
@router.post("/confirm", response_model=BoardRead)
|
||||
async def confirm_onboarding(
|
||||
async def confirm_onboarding( # noqa: C901, PLR0912, PLR0915
|
||||
payload: BoardOnboardingConfirm,
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = ADMIN_AUTH_DEP,
|
||||
) -> Board:
|
||||
"""Confirm onboarding results and provision the board lead agent."""
|
||||
onboarding = (
|
||||
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
||||
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
||||
@@ -409,7 +458,9 @@ async def confirm_onboarding(
|
||||
if lead_agent.update_cadence:
|
||||
lead_identity_profile["update_cadence"] = lead_agent.update_cadence
|
||||
if lead_agent.custom_instructions:
|
||||
lead_identity_profile["custom_instructions"] = lead_agent.custom_instructions
|
||||
lead_identity_profile["custom_instructions"] = (
|
||||
lead_agent.custom_instructions
|
||||
)
|
||||
|
||||
gateway, config = await _gateway_config(session, board)
|
||||
session.add(board)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Board CRUD and snapshot endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import (
|
||||
get_board_for_actor_read,
|
||||
@@ -47,9 +49,23 @@ from app.services.board_group_snapshot import build_board_group_snapshot
|
||||
from app.services.board_snapshot import build_board_snapshot
|
||||
from app.services.organizations import OrganizationContext, board_access_filter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/boards", tags=["boards"])
|
||||
|
||||
AGENT_SESSION_PREFIX = "agent"
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
||||
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
||||
BOARD_ACTOR_READ_DEP = Depends(get_board_for_actor_read)
|
||||
GATEWAY_ID_QUERY = Query(default=None)
|
||||
BOARD_GROUP_ID_QUERY = Query(default=None)
|
||||
INCLUDE_SELF_QUERY = Query(default=False)
|
||||
INCLUDE_DONE_QUERY = Query(default=False)
|
||||
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
@@ -83,10 +99,12 @@ async def _require_gateway(
|
||||
|
||||
async def _require_gateway_for_create(
|
||||
payload: BoardCreate,
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> Gateway:
|
||||
return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id)
|
||||
return await _require_gateway(
|
||||
session, payload.gateway_id, organization_id=ctx.organization.id,
|
||||
)
|
||||
|
||||
|
||||
async def _require_board_group(
|
||||
@@ -111,8 +129,8 @@ async def _require_board_group(
|
||||
|
||||
async def _require_board_group_for_create(
|
||||
payload: BoardCreate,
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardGroup | None:
|
||||
if payload.board_group_id is None:
|
||||
return None
|
||||
@@ -123,6 +141,10 @@ async def _require_board_group_for_create(
|
||||
)
|
||||
|
||||
|
||||
GATEWAY_CREATE_DEP = Depends(_require_gateway_for_create)
|
||||
BOARD_GROUP_CREATE_DEP = Depends(_require_board_group_for_create)
|
||||
|
||||
|
||||
async def _apply_board_update(
|
||||
*,
|
||||
payload: BoardUpdate,
|
||||
@@ -132,7 +154,7 @@ async def _apply_board_update(
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "gateway_id" in updates:
|
||||
await _require_gateway(
|
||||
session, updates["gateway_id"], organization_id=board.organization_id
|
||||
session, updates["gateway_id"], organization_id=board.organization_id,
|
||||
)
|
||||
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
||||
await _require_board_group(
|
||||
@@ -141,13 +163,15 @@ async def _apply_board_update(
|
||||
organization_id=board.organization_id,
|
||||
)
|
||||
crud.apply_updates(board, updates)
|
||||
if updates.get("board_type") == "goal":
|
||||
if (
|
||||
updates.get("board_type") == "goal"
|
||||
and (not board.objective or not board.success_metrics)
|
||||
):
|
||||
# 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",
|
||||
)
|
||||
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,
|
||||
@@ -158,7 +182,7 @@ async def _apply_board_update(
|
||||
|
||||
|
||||
async def _board_gateway(
|
||||
session: AsyncSession, board: Board
|
||||
session: AsyncSession, board: Board,
|
||||
) -> tuple[Gateway | None, GatewayClientConfig | None]:
|
||||
if not board.gateway_id:
|
||||
return None, None
|
||||
@@ -218,28 +242,32 @@ async def _cleanup_agent_on_gateway(
|
||||
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
|
||||
async def list_boards(
|
||||
gateway_id: UUID | None = Query(default=None),
|
||||
board_group_id: UUID | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
gateway_id: UUID | None = GATEWAY_ID_QUERY,
|
||||
board_group_id: UUID | None = BOARD_GROUP_ID_QUERY,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> DefaultLimitOffsetPage[BoardRead]:
|
||||
"""List boards visible to the current organization member."""
|
||||
statement = select(Board).where(board_access_filter(ctx.member, write=False))
|
||||
if gateway_id is not None:
|
||||
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
||||
if board_group_id is not None:
|
||||
statement = statement.where(col(Board.board_group_id) == board_group_id)
|
||||
statement = statement.order_by(func.lower(col(Board.name)).asc(), col(Board.created_at).desc())
|
||||
statement = statement.order_by(
|
||||
func.lower(col(Board.name)).asc(), col(Board.created_at).desc(),
|
||||
)
|
||||
return await paginate(session, statement)
|
||||
|
||||
|
||||
@router.post("", response_model=BoardRead)
|
||||
async def create_board(
|
||||
payload: BoardCreate,
|
||||
_gateway: Gateway = Depends(_require_gateway_for_create),
|
||||
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
_gateway: Gateway = GATEWAY_CREATE_DEP,
|
||||
_board_group: BoardGroup | None = BOARD_GROUP_CREATE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> Board:
|
||||
"""Create a board in the active organization."""
|
||||
data = payload.model_dump()
|
||||
data["organization_id"] = ctx.organization.id
|
||||
return await crud.create(session, Board, **data)
|
||||
@@ -247,27 +275,31 @@ async def create_board(
|
||||
|
||||
@router.get("/{board_id}", response_model=BoardRead)
|
||||
def get_board(
|
||||
board: Board = Depends(get_board_for_user_read),
|
||||
board: Board = BOARD_USER_READ_DEP,
|
||||
) -> Board:
|
||||
"""Get a board by id."""
|
||||
return board
|
||||
|
||||
|
||||
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
||||
async def get_board_snapshot(
|
||||
board: Board = Depends(get_board_for_actor_read),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
board: Board = BOARD_ACTOR_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardSnapshot:
|
||||
"""Get a board snapshot view model."""
|
||||
return await build_board_snapshot(session, board)
|
||||
|
||||
|
||||
@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),
|
||||
board: Board = Depends(get_board_for_actor_read),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
*,
|
||||
include_self: bool = INCLUDE_SELF_QUERY,
|
||||
include_done: bool = INCLUDE_DONE_QUERY,
|
||||
per_board_task_limit: int = PER_BOARD_TASK_LIMIT_QUERY,
|
||||
board: Board = BOARD_ACTOR_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> BoardGroupSnapshot:
|
||||
"""Get a grouped snapshot across related boards."""
|
||||
return await build_board_group_snapshot(
|
||||
session,
|
||||
board=board,
|
||||
@@ -280,19 +312,23 @@ async def get_board_group_snapshot(
|
||||
@router.patch("/{board_id}", response_model=BoardRead)
|
||||
async def update_board(
|
||||
payload: BoardUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
) -> Board:
|
||||
"""Update mutable board properties."""
|
||||
return await _apply_board_update(payload=payload, session=session, board=board)
|
||||
|
||||
|
||||
@router.delete("/{board_id}", response_model=OkResponse)
|
||||
async def delete_board(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
board: Board = BOARD_USER_WRITE_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a board and all dependent records."""
|
||||
agents = await Agent.objects.filter_by(board_id=board.id).all(session)
|
||||
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))
|
||||
task_ids = list(
|
||||
await session.exec(select(Task.id).where(Task.board_id == board.id)),
|
||||
)
|
||||
|
||||
config, client_config = await _board_gateway(session, board)
|
||||
if config and client_config:
|
||||
@@ -307,20 +343,31 @@ async def delete_board(
|
||||
|
||||
if task_ids:
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.task_id).in_(task_ids), commit=False
|
||||
session,
|
||||
ActivityEvent,
|
||||
col(ActivityEvent.task_id).in_(task_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, TaskDependency, col(TaskDependency.board_id) == board.id)
|
||||
await crud.delete_where(session, TaskFingerprint, col(TaskFingerprint.board_id) == board.id)
|
||||
await crud.delete_where(
|
||||
session, TaskDependency, col(TaskDependency.board_id) == board.id,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskFingerprint, col(TaskFingerprint.board_id) == board.id,
|
||||
)
|
||||
|
||||
# Approvals can reference tasks and agents, so delete before both.
|
||||
await crud.delete_where(session, Approval, col(Approval.board_id) == board.id)
|
||||
|
||||
await crud.delete_where(session, BoardMemory, col(BoardMemory.board_id) == board.id)
|
||||
await crud.delete_where(
|
||||
session, BoardOnboardingSession, col(BoardOnboardingSession.board_id) == board.id
|
||||
session,
|
||||
BoardOnboardingSession,
|
||||
col(BoardOnboardingSession.board_id) == board.id,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, OrganizationBoardAccess, col(OrganizationBoardAccess.board_id) == board.id
|
||||
session,
|
||||
OrganizationBoardAccess,
|
||||
col(OrganizationBoardAccess.board_id) == board.id,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
@@ -328,14 +375,17 @@ async def delete_board(
|
||||
col(OrganizationInviteBoardAccess.board_id) == board.id,
|
||||
)
|
||||
|
||||
# Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so
|
||||
# Tasks reference agents and have dependent records.
|
||||
# delete tasks before agents.
|
||||
await crud.delete_where(session, Task, col(Task.board_id) == board.id)
|
||||
|
||||
if agents:
|
||||
agent_ids = [agent.id for agent in agents]
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.agent_id).in_(agent_ids), commit=False
|
||||
session,
|
||||
ActivityEvent,
|
||||
col(ActivityEvent.agent_id).in_(agent_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Agent, col(Agent.id).in_(agent_ids))
|
||||
await session.delete(board)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Organization management endpoints and membership/invite flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from typing import Any, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.api.deps import require_org_admin, require_org_member
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.core.auth import get_auth_context
|
||||
from app.core.time import utcnow
|
||||
from app.db import crud
|
||||
from app.db.pagination import paginate
|
||||
@@ -63,10 +64,21 @@ from app.services.organizations import (
|
||||
set_active_organization,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
AUTH_DEP = Depends(get_auth_context)
|
||||
ORG_MEMBER_DEP = Depends(require_org_member)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
|
||||
|
||||
def _member_to_read(member: OrganizationMember, user: User | None) -> OrganizationMemberRead:
|
||||
def _member_to_read(
|
||||
member: OrganizationMember, user: User | None,
|
||||
) -> OrganizationMemberRead:
|
||||
model = OrganizationMemberRead.model_validate(member, from_attributes=True)
|
||||
if user is not None:
|
||||
model.user = OrganizationUserRead.model_validate(user, from_attributes=True)
|
||||
@@ -100,9 +112,10 @@ async def _require_org_invite(
|
||||
@router.post("", response_model=OrganizationRead)
|
||||
async def create_organization(
|
||||
payload: OrganizationCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Create an organization and assign the caller as owner."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
name = payload.name.strip()
|
||||
@@ -110,7 +123,9 @@ async def create_organization(
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
existing = (
|
||||
await session.exec(
|
||||
select(Organization).where(func.lower(col(Organization.name)) == name.lower())
|
||||
select(Organization).where(
|
||||
func.lower(col(Organization.name)) == name.lower(),
|
||||
),
|
||||
)
|
||||
).first()
|
||||
if existing is not None:
|
||||
@@ -140,19 +155,25 @@ async def create_organization(
|
||||
|
||||
@router.get("/me/list", response_model=list[OrganizationListItem])
|
||||
async def list_my_organizations(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> list[OrganizationListItem]:
|
||||
"""List organizations where the current user is a member."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
await get_active_membership(session, auth.user)
|
||||
db_user = await User.objects.by_id(auth.user.id).first(session)
|
||||
active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id
|
||||
active_id = (
|
||||
db_user.active_organization_id if db_user else auth.user.active_organization_id
|
||||
)
|
||||
|
||||
statement = (
|
||||
select(Organization, OrganizationMember)
|
||||
.join(OrganizationMember, col(OrganizationMember.organization_id) == col(Organization.id))
|
||||
.join(
|
||||
OrganizationMember,
|
||||
col(OrganizationMember.organization_id) == col(Organization.id),
|
||||
)
|
||||
.where(col(OrganizationMember.user_id) == auth.user.id)
|
||||
.order_by(func.lower(col(Organization.name)).asc())
|
||||
)
|
||||
@@ -171,30 +192,37 @@ async def list_my_organizations(
|
||||
@router.patch("/me/active", response_model=OrganizationRead)
|
||||
async def set_active_org(
|
||||
payload: OrganizationActiveUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Set the caller's active organization."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
member = await set_active_organization(
|
||||
session, user=auth.user, organization_id=payload.organization_id
|
||||
session, user=auth.user, organization_id=payload.organization_id,
|
||||
)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(
|
||||
session,
|
||||
)
|
||||
organization = await Organization.objects.by_id(member.organization_id).first(session)
|
||||
if organization is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return OrganizationRead.model_validate(organization, from_attributes=True)
|
||||
|
||||
|
||||
@router.get("/me", response_model=OrganizationRead)
|
||||
async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) -> OrganizationRead:
|
||||
async def get_my_org(
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationRead:
|
||||
"""Return the caller's active organization."""
|
||||
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
|
||||
|
||||
|
||||
@router.delete("/me", response_model=OkResponse)
|
||||
async def delete_my_org(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete the active organization and related entities."""
|
||||
if ctx.member.role != "owner":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -206,28 +234,39 @@ async def delete_my_org(
|
||||
task_ids = select(Task.id).where(col(Task.board_id).in_(board_ids))
|
||||
agent_ids = select(Agent.id).where(col(Agent.board_id).in_(board_ids))
|
||||
member_ids = select(OrganizationMember.id).where(
|
||||
col(OrganizationMember.organization_id) == org_id
|
||||
col(OrganizationMember.organization_id) == org_id,
|
||||
)
|
||||
invite_ids = select(OrganizationInvite.id).where(
|
||||
col(OrganizationInvite.organization_id) == org_id
|
||||
col(OrganizationInvite.organization_id) == org_id,
|
||||
)
|
||||
group_ids = select(BoardGroup.id).where(col(BoardGroup.organization_id) == org_id)
|
||||
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.task_id).in_(task_ids), commit=False
|
||||
session, ActivityEvent, col(ActivityEvent.task_id).in_(task_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.agent_id).in_(agent_ids), commit=False
|
||||
session,
|
||||
ActivityEvent,
|
||||
col(ActivityEvent.agent_id).in_(agent_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskDependency, col(TaskDependency.board_id).in_(board_ids), commit=False
|
||||
session,
|
||||
TaskDependency,
|
||||
col(TaskDependency.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskFingerprint, col(TaskFingerprint.board_id).in_(board_ids), commit=False
|
||||
session,
|
||||
TaskFingerprint,
|
||||
col(TaskFingerprint.board_id).in_(board_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Approval, col(Approval.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(
|
||||
session, BoardMemory, col(BoardMemory.board_id).in_(board_ids), commit=False
|
||||
session, Approval, col(Approval.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, BoardMemory, col(BoardMemory.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
@@ -259,9 +298,15 @@ async def delete_my_org(
|
||||
col(OrganizationInviteBoardAccess.organization_invite_id).in_(invite_ids),
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Task, col(Task.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(session, Agent, col(Agent.board_id).in_(board_ids), commit=False)
|
||||
await crud.delete_where(session, Board, col(Board.organization_id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session, Task, col(Task.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Agent, col(Agent.board_id).in_(board_ids), commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Board, col(Board.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
BoardGroupMemory,
|
||||
@@ -269,9 +314,11 @@ async def delete_my_org(
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, BoardGroup, col(BoardGroup.organization_id) == org_id, commit=False
|
||||
session, BoardGroup, col(BoardGroup.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Gateway, col(Gateway.organization_id) == org_id, commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Gateway, col(Gateway.organization_id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
OrganizationInvite,
|
||||
@@ -291,32 +338,39 @@ async def delete_my_org(
|
||||
active_organization_id=None,
|
||||
commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Organization, col(Organization.id) == org_id, commit=False)
|
||||
await crud.delete_where(
|
||||
session, Organization, col(Organization.id) == org_id, commit=False,
|
||||
)
|
||||
await session.commit()
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/me/member", response_model=OrganizationMemberRead)
|
||||
async def get_my_membership(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Get the caller's membership record in the active organization."""
|
||||
user = await User.objects.by_id(ctx.member.user_id).first(session)
|
||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
||||
organization_member_id=ctx.member.id
|
||||
organization_member_id=ctx.member.id,
|
||||
).all(session)
|
||||
model = _member_to_read(ctx.member, user)
|
||||
model.board_access = [
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True)
|
||||
for row in access_rows
|
||||
]
|
||||
return model
|
||||
|
||||
|
||||
@router.get("/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead])
|
||||
@router.get(
|
||||
"/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead],
|
||||
)
|
||||
async def list_org_members(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> DefaultLimitOffsetPage[OrganizationMemberRead]:
|
||||
"""List members for the active organization."""
|
||||
statement = (
|
||||
select(OrganizationMember, User)
|
||||
.join(User, col(User.id) == col(OrganizationMember.user_id))
|
||||
@@ -336,9 +390,10 @@ async def list_org_members(
|
||||
@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead)
|
||||
async def get_org_member(
|
||||
member_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_member),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Get a specific organization member by id."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -348,11 +403,12 @@ async def get_org_member(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
user = await User.objects.by_id(member.user_id).first(session)
|
||||
access_rows = await OrganizationBoardAccess.objects.filter_by(
|
||||
organization_member_id=member.id
|
||||
organization_member_id=member.id,
|
||||
).all(session)
|
||||
model = _member_to_read(member, user)
|
||||
model.board_access = [
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) for row in access_rows
|
||||
OrganizationBoardAccessRead.model_validate(row, from_attributes=True)
|
||||
for row in access_rows
|
||||
]
|
||||
return model
|
||||
|
||||
@@ -361,9 +417,10 @@ async def get_org_member(
|
||||
async def update_org_member(
|
||||
member_id: UUID,
|
||||
payload: OrganizationMemberUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Update a member's role in the organization."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -382,9 +439,10 @@ async def update_org_member(
|
||||
async def update_member_access(
|
||||
member_id: UUID,
|
||||
payload: OrganizationMemberAccessUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Update board-level access settings for a member."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -395,7 +453,9 @@ async def update_member_access(
|
||||
if board_ids:
|
||||
valid_board_ids = {
|
||||
board.id
|
||||
for board in await Board.objects.filter_by(organization_id=ctx.organization.id)
|
||||
for board in await Board.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(Board.id).in_(board_ids))
|
||||
.all(session)
|
||||
}
|
||||
@@ -412,9 +472,10 @@ async def update_member_access(
|
||||
@router.delete("/me/members/{member_id}", response_model=OkResponse)
|
||||
async def remove_org_member(
|
||||
member_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OkResponse:
|
||||
"""Remove a member from the active organization."""
|
||||
member = await _require_org_member(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -432,7 +493,9 @@ async def remove_org_member(
|
||||
)
|
||||
if member.role == "owner":
|
||||
owners = (
|
||||
await OrganizationMember.objects.filter_by(organization_id=ctx.organization.id)
|
||||
await OrganizationMember.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(OrganizationMember.role) == "owner")
|
||||
.all(session)
|
||||
)
|
||||
@@ -463,7 +526,9 @@ async def remove_org_member(
|
||||
user.active_organization_id = fallback_membership
|
||||
else:
|
||||
user.active_organization_id = (
|
||||
fallback_membership.organization_id if fallback_membership is not None else None
|
||||
fallback_membership.organization_id
|
||||
if fallback_membership is not None
|
||||
else None
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -471,11 +536,14 @@ async def remove_org_member(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead])
|
||||
@router.get(
|
||||
"/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead],
|
||||
)
|
||||
async def list_org_invites(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
|
||||
"""List pending invites for the active organization."""
|
||||
statement = (
|
||||
OrganizationInvite.objects.filter_by(organization_id=ctx.organization.id)
|
||||
.filter(col(OrganizationInvite.accepted_at).is_(None))
|
||||
@@ -488,9 +556,10 @@ async def list_org_invites(
|
||||
@router.post("/me/invites", response_model=OrganizationInviteRead)
|
||||
async def create_org_invite(
|
||||
payload: OrganizationInviteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationInviteRead:
|
||||
"""Create an organization invite for an email address."""
|
||||
email = normalize_invited_email(payload.invited_email)
|
||||
if not email:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
@@ -526,13 +595,17 @@ async def create_org_invite(
|
||||
if board_ids:
|
||||
valid_board_ids = {
|
||||
board.id
|
||||
for board in await Board.objects.filter_by(organization_id=ctx.organization.id)
|
||||
for board in await Board.objects.filter_by(
|
||||
organization_id=ctx.organization.id,
|
||||
)
|
||||
.filter(col(Board.id).in_(board_ids))
|
||||
.all(session)
|
||||
}
|
||||
if valid_board_ids != board_ids:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
await apply_invite_board_access(session, invite=invite, entries=payload.board_access)
|
||||
await apply_invite_board_access(
|
||||
session, invite=invite, entries=payload.board_access,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(invite)
|
||||
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
|
||||
@@ -541,9 +614,10 @@ async def create_org_invite(
|
||||
@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead)
|
||||
async def revoke_org_invite(
|
||||
invite_id: UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
ctx: OrganizationContext = Depends(require_org_admin),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> OrganizationInviteRead:
|
||||
"""Revoke a pending invite from the active organization."""
|
||||
invite = await _require_org_invite(
|
||||
session,
|
||||
organization_id=ctx.organization.id,
|
||||
@@ -562,9 +636,10 @@ async def revoke_org_invite(
|
||||
@router.post("/invites/accept", response_model=OrganizationMemberRead)
|
||||
async def accept_org_invite(
|
||||
payload: OrganizationInviteAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_DEP,
|
||||
) -> OrganizationMemberRead:
|
||||
"""Accept an invite and return resulting membership."""
|
||||
if auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
invite = await OrganizationInvite.objects.filter(
|
||||
@@ -573,11 +648,13 @@ async def accept_org_invite(
|
||||
).first(session)
|
||||
if invite is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
if invite.invited_email and auth.user.email:
|
||||
if normalize_invited_email(invite.invited_email) != normalize_invited_email(
|
||||
auth.user.email
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if (
|
||||
invite.invited_email
|
||||
and auth.user.email
|
||||
and normalize_invited_email(invite.invited_email)
|
||||
!= normalize_invited_email(auth.user.email)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
existing = await get_member(
|
||||
session,
|
||||
|
||||
@@ -1,41 +1,54 @@
|
||||
"""API-level thin wrapper around query-set helpers with HTTP conveniences."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
from typing import TYPE_CHECKING, Generic, TypeVar
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.sql.expression import SelectOfScalar
|
||||
|
||||
from app.db.queryset import QuerySet, qs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.sql.expression import SelectOfScalar
|
||||
|
||||
ModelT = TypeVar("ModelT")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class APIQuerySet(Generic[ModelT]):
|
||||
"""Immutable query-set wrapper tailored for API-layer usage."""
|
||||
|
||||
queryset: QuerySet[ModelT]
|
||||
|
||||
@property
|
||||
def statement(self) -> SelectOfScalar[ModelT]:
|
||||
"""Expose the underlying SQL statement for advanced composition."""
|
||||
return self.queryset.statement
|
||||
|
||||
def filter(self, *criteria: Any) -> APIQuerySet[ModelT]:
|
||||
def filter(self, *criteria: object) -> APIQuerySet[ModelT]:
|
||||
"""Return a new queryset with additional SQL criteria applied."""
|
||||
return APIQuerySet(self.queryset.filter(*criteria))
|
||||
|
||||
def order_by(self, *ordering: Any) -> APIQuerySet[ModelT]:
|
||||
def order_by(self, *ordering: object) -> APIQuerySet[ModelT]:
|
||||
"""Return a new queryset with ordering clauses applied."""
|
||||
return APIQuerySet(self.queryset.order_by(*ordering))
|
||||
|
||||
def limit(self, value: int) -> APIQuerySet[ModelT]:
|
||||
"""Return a new queryset with a row limit applied."""
|
||||
return APIQuerySet(self.queryset.limit(value))
|
||||
|
||||
def offset(self, value: int) -> APIQuerySet[ModelT]:
|
||||
"""Return a new queryset with an offset applied."""
|
||||
return APIQuerySet(self.queryset.offset(value))
|
||||
|
||||
async def all(self, session: AsyncSession) -> list[ModelT]:
|
||||
"""Fetch all rows for the current queryset."""
|
||||
return await self.queryset.all(session)
|
||||
|
||||
async def first(self, session: AsyncSession) -> ModelT | None:
|
||||
"""Fetch the first row for the current queryset, if present."""
|
||||
return await self.queryset.first(session)
|
||||
|
||||
async def first_or_404(
|
||||
@@ -44,6 +57,7 @@ class APIQuerySet(Generic[ModelT]):
|
||||
*,
|
||||
detail: str | None = None,
|
||||
) -> ModelT:
|
||||
"""Fetch the first row or raise HTTP 404 when no row exists."""
|
||||
obj = await self.first(session)
|
||||
if obj is not None:
|
||||
return obj
|
||||
@@ -53,4 +67,5 @@ class APIQuerySet(Generic[ModelT]):
|
||||
|
||||
|
||||
def api_qs(model: type[ModelT]) -> APIQuerySet[ModelT]:
|
||||
"""Create an APIQuerySet for a SQLModel class."""
|
||||
return APIQuerySet(qs(model))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""API routes for searching and fetching souls-directory markdown entries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -13,6 +15,7 @@ from app.schemas.souls_directory import (
|
||||
from app.services import souls_directory
|
||||
|
||||
router = APIRouter(prefix="/souls-directory", tags=["souls-directory"])
|
||||
ADMIN_OR_AGENT_DEP = Depends(require_admin_or_agent)
|
||||
|
||||
_SAFE_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
_SAFE_SLUG_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
||||
@@ -41,8 +44,9 @@ def _validate_segment(value: str, *, field: str) -> str:
|
||||
async def search(
|
||||
q: str = Query(default="", min_length=0),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
_actor: ActorContext = Depends(require_admin_or_agent),
|
||||
_actor: ActorContext = ADMIN_OR_AGENT_DEP,
|
||||
) -> SoulsDirectorySearchResponse:
|
||||
"""Search souls-directory entries by handle/slug query text."""
|
||||
refs = await souls_directory.list_souls_directory_refs()
|
||||
matches = souls_directory.search_souls(refs, query=q, limit=limit)
|
||||
items = [
|
||||
@@ -62,12 +66,23 @@ async def search(
|
||||
async def get_markdown(
|
||||
handle: str,
|
||||
slug: str,
|
||||
_actor: ActorContext = Depends(require_admin_or_agent),
|
||||
_actor: ActorContext = ADMIN_OR_AGENT_DEP,
|
||||
) -> SoulsDirectoryMarkdownResponse:
|
||||
"""Fetch markdown content for a validated souls-directory handle and slug."""
|
||||
safe_handle = _validate_segment(handle, field="handle")
|
||||
safe_slug = _validate_segment(slug.removesuffix(".md"), field="slug")
|
||||
try:
|
||||
content = await souls_directory.fetch_soul_markdown(handle=safe_handle, slug=safe_slug)
|
||||
content = await souls_directory.fetch_soul_markdown(
|
||||
handle=safe_handle,
|
||||
slug=safe_slug,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
return SoulsDirectoryMarkdownResponse(handle=safe_handle, slug=safe_slug, content=content)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return SoulsDirectoryMarkdownResponse(
|
||||
handle=safe_handle,
|
||||
slug=safe_slug,
|
||||
content=content,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"""Task API routes for listing, streaming, and mutating board tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections import deque
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import asc, desc, or_
|
||||
from sqlmodel import col, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.sql.expression import Select
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
@@ -23,13 +25,16 @@ from app.api.deps import (
|
||||
require_admin_auth,
|
||||
require_admin_or_agent,
|
||||
)
|
||||
from app.core.auth import AuthContext
|
||||
from app.core.time import utcnow
|
||||
from app.db import crud
|
||||
from app.db.pagination import paginate
|
||||
from app.db.session import async_session_maker, get_session
|
||||
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
|
||||
from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message
|
||||
from app.integrations.openclaw_gateway import (
|
||||
OpenClawGatewayError,
|
||||
ensure_session,
|
||||
send_message,
|
||||
)
|
||||
from app.models.activity_events import ActivityEvent
|
||||
from app.models.agents import Agent
|
||||
from app.models.approvals import Approval
|
||||
@@ -41,7 +46,13 @@ from app.models.tasks import Task
|
||||
from app.schemas.common import OkResponse
|
||||
from app.schemas.errors import BlockedTaskError
|
||||
from app.schemas.pagination import DefaultLimitOffsetPage
|
||||
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from app.schemas.tasks import (
|
||||
TaskCommentCreate,
|
||||
TaskCommentRead,
|
||||
TaskCreate,
|
||||
TaskRead,
|
||||
TaskUpdate,
|
||||
)
|
||||
from app.services.activity_log import record_activity
|
||||
from app.services.mentions import extract_mentions, matches_agent_mention
|
||||
from app.services.organizations import require_board_access
|
||||
@@ -54,6 +65,11 @@ from app.services.task_dependencies import (
|
||||
validate_dependency_update,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext
|
||||
|
||||
router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
|
||||
|
||||
ALLOWED_STATUSES = {"inbox", "in_progress", "review", "done"}
|
||||
@@ -66,6 +82,14 @@ TASK_EVENT_TYPES = {
|
||||
SSE_SEEN_MAX = 2000
|
||||
TASK_SNIPPET_MAX_LEN = 500
|
||||
TASK_SNIPPET_TRUNCATED_LEN = 497
|
||||
BOARD_READ_DEP = Depends(get_board_for_actor_read)
|
||||
ACTOR_DEP = Depends(require_admin_or_agent)
|
||||
SINCE_QUERY = Query(default=None)
|
||||
STATUS_QUERY = Query(default=None, alias="status")
|
||||
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
ADMIN_AUTH_DEP = Depends(require_admin_auth)
|
||||
TASK_DEP = Depends(get_task_or_404)
|
||||
|
||||
|
||||
def _comment_validation_error() -> HTTPException:
|
||||
@@ -98,6 +122,7 @@ async def has_valid_recent_comment(
|
||||
agent_id: UUID | None,
|
||||
since: datetime | None,
|
||||
) -> bool:
|
||||
"""Check whether the task has a recent non-empty comment by the agent."""
|
||||
if agent_id is None or since is None:
|
||||
return False
|
||||
statement = (
|
||||
@@ -180,8 +205,8 @@ async def _reconcile_dependents_for_dependency_toggle(
|
||||
await session.exec(
|
||||
select(Task)
|
||||
.where(col(Task.board_id) == board_id)
|
||||
.where(col(Task.id).in_(dependent_ids))
|
||||
)
|
||||
.where(col(Task.id).in_(dependent_ids)),
|
||||
),
|
||||
)
|
||||
reopened = previous_status == "done" and dependency_task.status != "done"
|
||||
|
||||
@@ -204,7 +229,10 @@ async def _reconcile_dependents_for_dependency_toggle(
|
||||
session,
|
||||
event_type="task.status_changed",
|
||||
task_id=dependent.id,
|
||||
message=f"Task returned to inbox: dependency reopened ({dependency_task.title}).",
|
||||
message=(
|
||||
"Task returned to inbox: dependency reopened "
|
||||
f"({dependency_task.title})."
|
||||
),
|
||||
agent_id=actor_agent_id,
|
||||
)
|
||||
else:
|
||||
@@ -230,7 +258,9 @@ async def _fetch_task_events(
|
||||
board_id: UUID,
|
||||
since: datetime,
|
||||
) -> list[tuple[ActivityEvent, Task | None]]:
|
||||
task_ids = list(await session.exec(select(Task.id).where(col(Task.board_id) == board_id)))
|
||||
task_ids = list(
|
||||
await session.exec(select(Task.id).where(col(Task.board_id) == board_id)),
|
||||
)
|
||||
if not task_ids:
|
||||
return []
|
||||
statement = cast(
|
||||
@@ -249,7 +279,9 @@ def _serialize_comment(event: ActivityEvent) -> dict[str, object]:
|
||||
return TaskCommentRead.model_validate(event).model_dump(mode="json")
|
||||
|
||||
|
||||
async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientConfig | None:
|
||||
async def _gateway_config(
|
||||
session: AsyncSession, board: Board,
|
||||
) -> GatewayClientConfig | None:
|
||||
if not board.gateway_id:
|
||||
return None
|
||||
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
|
||||
@@ -303,7 +335,10 @@ async def _notify_agent_on_task_assign(
|
||||
message = (
|
||||
"TASK ASSIGNED\n"
|
||||
+ "\n".join(details)
|
||||
+ "\n\nTake action: open the task and begin work. Post updates as task comments."
|
||||
+ (
|
||||
"\n\nTake action: open the task and begin work. "
|
||||
"Post updates as task comments."
|
||||
)
|
||||
)
|
||||
try:
|
||||
await _send_agent_task_message(
|
||||
@@ -442,17 +477,18 @@ async def _notify_lead_on_task_unassigned(
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream_tasks(
|
||||
async def stream_tasks( # noqa: C901
|
||||
request: Request,
|
||||
board: Board = Depends(get_board_for_actor_read),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
since: str | None = Query(default=None),
|
||||
board: Board = BOARD_READ_DEP,
|
||||
_actor: ActorContext = ACTOR_DEP,
|
||||
since: str | None = SINCE_QUERY,
|
||||
) -> EventSourceResponse:
|
||||
"""Stream task and task-comment events as SSE payloads."""
|
||||
since_dt = _parse_since(since) or utcnow()
|
||||
seen_ids: set[UUID] = set()
|
||||
seen_queue: deque[UUID] = deque()
|
||||
|
||||
async def event_generator() -> AsyncIterator[dict[str, str]]:
|
||||
async def event_generator() -> AsyncIterator[dict[str, str]]: # noqa: C901
|
||||
last_seen = since_dt
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
@@ -510,7 +546,7 @@ async def stream_tasks(
|
||||
"depends_on_task_ids": dep_list,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
}
|
||||
},
|
||||
)
|
||||
.model_dump(mode="json")
|
||||
)
|
||||
@@ -521,14 +557,15 @@ async def stream_tasks(
|
||||
|
||||
|
||||
@router.get("", response_model=DefaultLimitOffsetPage[TaskRead])
|
||||
async def list_tasks(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
async def list_tasks( # noqa: C901
|
||||
status_filter: str | None = STATUS_QUERY,
|
||||
assigned_agent_id: UUID | None = None,
|
||||
unassigned: bool | None = None,
|
||||
board: Board = Depends(get_board_for_actor_read),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
board: Board = BOARD_READ_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
_actor: ActorContext = ACTOR_DEP,
|
||||
) -> DefaultLimitOffsetPage[TaskRead]:
|
||||
"""List board tasks with optional status and assignment filters."""
|
||||
statement = select(Task).where(Task.board_id == board.id)
|
||||
if status_filter:
|
||||
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
||||
@@ -550,7 +587,9 @@ async def list_tasks(
|
||||
if not tasks:
|
||||
return []
|
||||
task_ids = [task.id for task in tasks]
|
||||
deps_map = await dependency_ids_by_task_id(session, board_id=board.id, task_ids=task_ids)
|
||||
deps_map = await dependency_ids_by_task_id(
|
||||
session, board_id=board.id, task_ids=task_ids,
|
||||
)
|
||||
dep_ids: list[UUID] = []
|
||||
for value in deps_map.values():
|
||||
dep_ids.extend(value)
|
||||
@@ -563,7 +602,9 @@ async def list_tasks(
|
||||
output: list[TaskRead] = []
|
||||
for task in tasks:
|
||||
dep_list = deps_map.get(task.id, [])
|
||||
blocked_by = blocked_by_dependency_ids(dependency_ids=dep_list, status_by_id=dep_status)
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=dep_list, status_by_id=dep_status,
|
||||
)
|
||||
if task.status == "done":
|
||||
blocked_by = []
|
||||
output.append(
|
||||
@@ -572,8 +613,8 @@ async def list_tasks(
|
||||
"depends_on_task_ids": dep_list,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
return output
|
||||
|
||||
@@ -583,10 +624,11 @@ async def list_tasks(
|
||||
@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
|
||||
async def create_task(
|
||||
payload: TaskCreate,
|
||||
board: Board = Depends(get_board_for_user_write),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
board: Board = BOARD_WRITE_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = ADMIN_AUTH_DEP,
|
||||
) -> TaskRead:
|
||||
"""Create a task and initialize dependency rows."""
|
||||
data = payload.model_dump()
|
||||
depends_on_task_ids = cast(list[UUID], data.pop("depends_on_task_ids", []) or [])
|
||||
|
||||
@@ -606,7 +648,9 @@ async def create_task(
|
||||
board_id=board.id,
|
||||
dependency_ids=normalized_deps,
|
||||
)
|
||||
blocked_by = blocked_by_dependency_ids(dependency_ids=normalized_deps, status_by_id=dep_status)
|
||||
blocked_by = blocked_by_dependency_ids(
|
||||
dependency_ids=normalized_deps, status_by_id=dep_status,
|
||||
)
|
||||
if blocked_by and (task.assigned_agent_id is not None or task.status != "inbox"):
|
||||
raise _blocked_task_error(blocked_by)
|
||||
session.add(task)
|
||||
@@ -618,7 +662,7 @@ async def create_task(
|
||||
board_id=board.id,
|
||||
task_id=task.id,
|
||||
depends_on_task_id=dep_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
@@ -632,7 +676,9 @@ async def create_task(
|
||||
await session.commit()
|
||||
await _notify_lead_on_task_create(session=session, board=board, task=task)
|
||||
if task.assigned_agent_id:
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
|
||||
session,
|
||||
)
|
||||
if assigned_agent:
|
||||
await _notify_agent_on_task_assign(
|
||||
session=session,
|
||||
@@ -645,7 +691,7 @@ async def create_task(
|
||||
"depends_on_task_ids": normalized_deps,
|
||||
"blocked_by_task_ids": blocked_by,
|
||||
"is_blocked": bool(blocked_by),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -654,12 +700,13 @@ async def create_task(
|
||||
response_model=TaskRead,
|
||||
responses={409: {"model": BlockedTaskError}},
|
||||
)
|
||||
async def update_task(
|
||||
async def update_task( # noqa: C901, PLR0912, PLR0915
|
||||
payload: TaskUpdate,
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> TaskRead:
|
||||
"""Update task status, assignment, comment, and dependency state."""
|
||||
if task.board_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -676,7 +723,9 @@ async def update_task(
|
||||
previous_assigned = task.assigned_agent_id
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
comment = updates.pop("comment", None)
|
||||
depends_on_task_ids = cast(list[UUID] | None, updates.pop("depends_on_task_ids", None))
|
||||
depends_on_task_ids = cast(
|
||||
list[UUID] | None, updates.pop("depends_on_task_ids", None),
|
||||
)
|
||||
|
||||
requested_fields = set(updates)
|
||||
if comment is not None:
|
||||
@@ -685,7 +734,9 @@ async def update_task(
|
||||
requested_fields.add("depends_on_task_ids")
|
||||
|
||||
async def _current_dep_ids() -> list[UUID]:
|
||||
deps_map = await dependency_ids_by_task_id(session, board_id=board_id, task_ids=[task.id])
|
||||
deps_map = await dependency_ids_by_task_id(
|
||||
session, board_id=board_id, task_ids=[task.id],
|
||||
)
|
||||
return deps_map.get(task.id, [])
|
||||
|
||||
async def _blocked_by(dep_ids: Sequence[UUID]) -> list[UUID]:
|
||||
@@ -696,16 +747,20 @@ async def update_task(
|
||||
board_id=board_id,
|
||||
dependency_ids=list(dep_ids),
|
||||
)
|
||||
return blocked_by_dependency_ids(dependency_ids=list(dep_ids), status_by_id=dep_status)
|
||||
return blocked_by_dependency_ids(
|
||||
dependency_ids=list(dep_ids), status_by_id=dep_status,
|
||||
)
|
||||
|
||||
# Lead agent: delegation only (assign/unassign, resolve review, manage dependencies).
|
||||
# Lead agent: delegation only.
|
||||
# Assign/unassign, resolve review, and manage dependencies.
|
||||
if actor.actor_type == "agent" and actor.agent and actor.agent.is_board_lead:
|
||||
allowed_fields = {"assigned_agent_id", "status", "depends_on_task_ids"}
|
||||
if comment is not None or not requested_fields.issubset(allowed_fields):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Board leads can only assign/unassign tasks, update dependencies, or resolve review tasks."
|
||||
"Board leads can only assign/unassign tasks, update "
|
||||
"dependencies, or resolve review tasks."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -745,7 +800,11 @@ async def update_task(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads cannot assign tasks to themselves.",
|
||||
)
|
||||
if agent.board_id and task.board_id and agent.board_id != task.board_id:
|
||||
if (
|
||||
agent.board_id
|
||||
and task.board_id
|
||||
and agent.board_id != task.board_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
||||
task.assigned_agent_id = agent.id
|
||||
else:
|
||||
@@ -755,12 +814,18 @@ async def update_task(
|
||||
if task.status != "review":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads can only change status when a task is in review.",
|
||||
detail=(
|
||||
"Board leads can only change status when a task is "
|
||||
"in review."
|
||||
),
|
||||
)
|
||||
if updates["status"] not in {"done", "inbox"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Board leads can only move review tasks to done or inbox.",
|
||||
detail=(
|
||||
"Board leads can only move review tasks to done "
|
||||
"or inbox."
|
||||
),
|
||||
)
|
||||
if updates["status"] == "inbox":
|
||||
task.assigned_agent_id = None
|
||||
@@ -793,7 +858,9 @@ async def update_task(
|
||||
await session.refresh(task)
|
||||
|
||||
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
|
||||
session,
|
||||
)
|
||||
if assigned_agent:
|
||||
board = (
|
||||
await Board.objects.by_id(task.board_id).first(session)
|
||||
@@ -817,14 +884,18 @@ async def update_task(
|
||||
"depends_on_task_ids": dep_ids,
|
||||
"blocked_by_task_ids": blocked_ids,
|
||||
"is_blocked": bool(blocked_ids),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Non-lead agent: can only change status + comment, and cannot start blocked tasks.
|
||||
if actor.actor_type == "agent":
|
||||
if actor.agent and actor.agent.board_id and task.board_id:
|
||||
if actor.agent.board_id != task.board_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
if (
|
||||
actor.agent
|
||||
and actor.agent.board_id
|
||||
and task.board_id
|
||||
and actor.agent.board_id != task.board_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
allowed_fields = {"status", "comment"}
|
||||
if depends_on_task_ids is not None or not set(updates).issubset(allowed_fields):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
|
||||
@@ -858,14 +929,16 @@ async def update_task(
|
||||
)
|
||||
|
||||
effective_deps = (
|
||||
admin_normalized_deps if admin_normalized_deps is not None else await _current_dep_ids()
|
||||
admin_normalized_deps
|
||||
if admin_normalized_deps is not None
|
||||
else await _current_dep_ids()
|
||||
)
|
||||
blocked_ids = await _blocked_by(effective_deps)
|
||||
|
||||
target_status = cast(str, updates.get("status", task.status))
|
||||
if blocked_ids and not (task.status == "done" and target_status == "done"):
|
||||
# Blocked tasks cannot be assigned or moved out of inbox. If the task is already in
|
||||
# flight, force it back to inbox and unassign it.
|
||||
# Blocked tasks cannot be assigned or moved out of inbox.
|
||||
# If the task is already in flight, force it back to inbox and unassign it.
|
||||
task.status = "inbox"
|
||||
task.assigned_agent_id = None
|
||||
task.in_progress_at = None
|
||||
@@ -910,7 +983,9 @@ async def update_task(
|
||||
event_type="task.comment",
|
||||
message=comment,
|
||||
task_id=task.id,
|
||||
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
|
||||
agent_id=actor.agent.id
|
||||
if actor.actor_type == "agent" and actor.agent
|
||||
else None,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -921,7 +996,9 @@ async def update_task(
|
||||
else:
|
||||
event_type = "task.updated"
|
||||
message = f"Task updated: {task.title}."
|
||||
actor_agent_id = actor.agent.id if actor.actor_type == "agent" and actor.agent else None
|
||||
actor_agent_id = (
|
||||
actor.agent.id if actor.actor_type == "agent" and actor.agent else None
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
event_type=event_type,
|
||||
@@ -938,23 +1015,34 @@ async def update_task(
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
if task.status == "inbox" and task.assigned_agent_id is None:
|
||||
if previous_status != "inbox" or previous_assigned is not None:
|
||||
board = (
|
||||
await Board.objects.by_id(task.board_id).first(session) if task.board_id else None
|
||||
if (
|
||||
task.status == "inbox"
|
||||
and task.assigned_agent_id is None
|
||||
and (previous_status != "inbox" or previous_assigned is not None)
|
||||
):
|
||||
board = (
|
||||
await Board.objects.by_id(task.board_id).first(session)
|
||||
if task.board_id
|
||||
else None
|
||||
)
|
||||
if board:
|
||||
await _notify_lead_on_task_unassigned(
|
||||
session=session,
|
||||
board=board,
|
||||
task=task,
|
||||
)
|
||||
if board:
|
||||
await _notify_lead_on_task_unassigned(
|
||||
session=session,
|
||||
board=board,
|
||||
task=task,
|
||||
)
|
||||
if task.assigned_agent_id and task.assigned_agent_id != previous_assigned:
|
||||
if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id:
|
||||
if (
|
||||
actor.actor_type == "agent"
|
||||
and actor.agent
|
||||
and task.assigned_agent_id == actor.agent.id
|
||||
):
|
||||
# Don't notify the actor about their own assignment.
|
||||
pass
|
||||
else:
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
|
||||
session,
|
||||
)
|
||||
if assigned_agent:
|
||||
board = (
|
||||
await Board.objects.by_id(task.board_id).first(session)
|
||||
@@ -978,16 +1066,17 @@ async def update_task(
|
||||
"depends_on_task_ids": dep_ids,
|
||||
"blocked_by_task_ids": blocked_ids,
|
||||
"is_blocked": bool(blocked_ids),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{task_id}", response_model=OkResponse)
|
||||
async def delete_task(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
task: Task = Depends(get_task_or_404),
|
||||
auth: AuthContext = Depends(require_admin_auth),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
task: Task = TASK_DEP,
|
||||
auth: AuthContext = ADMIN_AUTH_DEP,
|
||||
) -> OkResponse:
|
||||
"""Delete a task and related records."""
|
||||
if task.board_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
board = await Board.objects.by_id(task.board_id).first(session)
|
||||
@@ -997,12 +1086,14 @@ async def delete_task(
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
await require_board_access(session, user=auth.user, board=board, write=True)
|
||||
await crud.delete_where(
|
||||
session, ActivityEvent, col(ActivityEvent.task_id) == task.id, commit=False
|
||||
session, ActivityEvent, col(ActivityEvent.task_id) == task.id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, TaskFingerprint, col(TaskFingerprint.task_id) == task.id, commit=False
|
||||
session, TaskFingerprint, col(TaskFingerprint.task_id) == task.id, commit=False,
|
||||
)
|
||||
await crud.delete_where(
|
||||
session, Approval, col(Approval.task_id) == task.id, commit=False,
|
||||
)
|
||||
await crud.delete_where(session, Approval, col(Approval.task_id) == task.id, commit=False)
|
||||
await crud.delete_where(
|
||||
session,
|
||||
TaskDependency,
|
||||
@@ -1017,11 +1108,14 @@ async def delete_task(
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@router.get("/{task_id}/comments", response_model=DefaultLimitOffsetPage[TaskCommentRead])
|
||||
@router.get(
|
||||
"/{task_id}/comments", response_model=DefaultLimitOffsetPage[TaskCommentRead],
|
||||
)
|
||||
async def list_task_comments(
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
) -> DefaultLimitOffsetPage[TaskCommentRead]:
|
||||
"""List comments for a task in chronological order."""
|
||||
statement = (
|
||||
select(ActivityEvent)
|
||||
.where(col(ActivityEvent.task_id) == task.id)
|
||||
@@ -1032,12 +1126,13 @@ async def list_task_comments(
|
||||
|
||||
|
||||
@router.post("/{task_id}/comments", response_model=TaskCommentRead)
|
||||
async def create_task_comment(
|
||||
async def create_task_comment( # noqa: C901, PLR0912
|
||||
payload: TaskCommentCreate,
|
||||
task: Task = Depends(get_task_or_404),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
task: Task = TASK_DEP,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
actor: ActorContext = ACTOR_DEP,
|
||||
) -> ActivityEvent:
|
||||
"""Create a task comment and notify relevant agents."""
|
||||
if task.board_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
|
||||
if actor.actor_type == "user" and actor.user is not None:
|
||||
@@ -1045,22 +1140,28 @@ async def create_task_comment(
|
||||
if board is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
await require_board_access(session, user=actor.user, board=board, write=True)
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
if actor.agent.is_board_lead and task.status != "review":
|
||||
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
|
||||
task, actor.agent
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Board leads can only comment during review, when mentioned, or on tasks they created."
|
||||
),
|
||||
)
|
||||
if (
|
||||
actor.actor_type == "agent"
|
||||
and actor.agent
|
||||
and actor.agent.is_board_lead
|
||||
and task.status != "review"
|
||||
and not await _lead_was_mentioned(session, task, actor.agent)
|
||||
and not _lead_created_task(task, actor.agent)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Board leads can only comment during review, when mentioned, "
|
||||
"or on tasks they created."
|
||||
),
|
||||
)
|
||||
event = ActivityEvent(
|
||||
event_type="task.comment",
|
||||
message=payload.message,
|
||||
task_id=task.id,
|
||||
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
|
||||
agent_id=actor.agent.id
|
||||
if actor.actor_type == "agent" and actor.agent
|
||||
else None,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
@@ -1072,17 +1173,27 @@ async def create_task_comment(
|
||||
if matches_agent_mention(agent, mention_names):
|
||||
targets[agent.id] = agent
|
||||
if not mention_names and task.assigned_agent_id:
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(session)
|
||||
assigned_agent = await Agent.objects.by_id(task.assigned_agent_id).first(
|
||||
session,
|
||||
)
|
||||
if assigned_agent:
|
||||
targets[assigned_agent.id] = assigned_agent
|
||||
if actor.actor_type == "agent" and actor.agent:
|
||||
targets.pop(actor.agent.id, None)
|
||||
if targets:
|
||||
board = await Board.objects.by_id(task.board_id).first(session) if task.board_id else None
|
||||
board = (
|
||||
await Board.objects.by_id(task.board_id).first(session)
|
||||
if task.board_id
|
||||
else None
|
||||
)
|
||||
config = await _gateway_config(session, board) if board else None
|
||||
if board and config:
|
||||
snippet = _truncate_snippet(payload.message)
|
||||
actor_name = actor.agent.name if actor.actor_type == "agent" and actor.agent else "User"
|
||||
actor_name = (
|
||||
actor.agent.name
|
||||
if actor.actor_type == "agent" and actor.agent
|
||||
else "User"
|
||||
)
|
||||
for agent in targets.values():
|
||||
if not agent.openclaw_session_id:
|
||||
continue
|
||||
@@ -1101,15 +1212,14 @@ async def create_task_comment(
|
||||
f"From: {actor_name}\n\n"
|
||||
f"{action_line}\n\n"
|
||||
f"Comment:\n{snippet}\n\n"
|
||||
"If you are mentioned but not assigned, reply in the task thread but do not change task status."
|
||||
"If you are mentioned but not assigned, reply in the task "
|
||||
"thread but do not change task status."
|
||||
)
|
||||
try:
|
||||
with suppress(OpenClawGatewayError):
|
||||
await _send_agent_task_message(
|
||||
session_key=agent.openclaw_session_id,
|
||||
config=config,
|
||||
agent_name=agent.name,
|
||||
message=message,
|
||||
)
|
||||
except OpenClawGatewayError:
|
||||
pass
|
||||
return event
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
"""User self-service API endpoints for profile retrieval and updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.users import User
|
||||
from app.schemas.users import UserRead, UserUpdate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from app.models.users import User
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
AUTH_CONTEXT_DEP = Depends(get_auth_context)
|
||||
SESSION_DEP = Depends(get_session)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def get_me(auth: AuthContext = Depends(get_auth_context)) -> UserRead:
|
||||
async def get_me(auth: AuthContext = AUTH_CONTEXT_DEP) -> UserRead:
|
||||
"""Return the authenticated user's current profile payload."""
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return UserRead.model_validate(auth.user)
|
||||
@@ -21,9 +31,10 @@ async def get_me(auth: AuthContext = Depends(get_auth_context)) -> UserRead:
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
auth: AuthContext = AUTH_CONTEXT_DEP,
|
||||
) -> UserRead:
|
||||
"""Apply partial profile updates for the authenticated user."""
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
|
||||
Reference in New Issue
Block a user