refactor: update module docstrings for clarity and consistency

This commit is contained in:
Abhimanyu Saharan
2026-02-09 15:49:50 +05:30
parent 78bb08d4a3
commit 7ca1899d9f
99 changed files with 2345 additions and 855 deletions

View File

@@ -0,0 +1 @@
"""API router modules for the OpenClaw Mission Control backend."""

View File

@@ -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(

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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))

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)