refactor: enhance docstrings for clarity and consistency across multiple files

This commit is contained in:
Abhimanyu Saharan
2026-02-09 16:23:41 +05:30
parent 7ca1899d9f
commit 7706943209
28 changed files with 1829 additions and 932 deletions

View File

@@ -1,17 +1,18 @@
"""Activity listing and task-comment feed endpoints."""
from __future__ import annotations
import asyncio
import json
from collections import deque
from collections.abc import AsyncIterator, Sequence
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, desc, func
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_member
@@ -22,7 +23,10 @@ from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
from app.schemas.activity_events import (
ActivityEventRead,
ActivityTaskCommentFeedItemRead,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
@@ -30,9 +34,21 @@ from app.services.organizations import (
list_accessible_board_ids,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(prefix="/activity", tags=["activity"])
SSE_SEEN_MAX = 2000
STREAM_POLL_SECONDS = 2
SESSION_DEP = Depends(get_session)
ACTOR_DEP = Depends(require_admin_or_agent)
ORG_MEMBER_DEP = Depends(require_org_member)
BOARD_ID_QUERY = Query(default=None)
SINCE_QUERY = Query(default=None)
_RUNTIME_TYPE_REFERENCES = (UUID,)
def _parse_since(value: str | None) -> datetime | None:
@@ -110,9 +126,10 @@ async def _fetch_task_comment_events(
@router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead])
async def list_activity(
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[ActivityEventRead]:
"""List activity events visible to the calling actor."""
statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
@@ -124,9 +141,10 @@ async def list_activity(
if not board_ids:
statement = statement.where(col(ActivityEvent.id).is_(None))
else:
statement = statement.join(Task, col(ActivityEvent.task_id) == col(Task.id)).where(
col(Task.board_id).in_(board_ids)
)
statement = statement.join(
Task,
col(ActivityEvent.task_id) == col(Task.id),
).where(col(Task.board_id).in_(board_ids))
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
return await paginate(session, statement)
@@ -136,10 +154,11 @@ async def list_activity(
response_model=DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead],
)
async def list_task_comment_feed(
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
board_id: UUID | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
"""List task-comment feed items for accessible boards."""
statement = (
select(ActivityEvent, Task, Board, Agent)
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
@@ -161,7 +180,10 @@ async def list_task_comment_feed(
def _transform(items: Sequence[Any]) -> Sequence[Any]:
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
return [_feed_item(event, task, board, agent) for event, task, board, agent in rows]
return [
_feed_item(event, task, board, agent)
for event, task, board, agent in rows
]
return await paginate(session, statement, transformer=_transform)
@@ -169,13 +191,18 @@ async def list_task_comment_feed(
@router.get("/task-comments/stream")
async def stream_task_comment_feed(
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_member),
board_id: UUID | None = BOARD_ID_QUERY,
since: str | None = SINCE_QUERY,
db_session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> EventSourceResponse:
"""Stream task-comment events for accessible boards."""
since_dt = _parse_since(since) or utcnow()
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
board_ids = await list_accessible_board_ids(
db_session,
member=ctx.member,
write=False,
)
allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
@@ -187,11 +214,15 @@ async def stream_task_comment_feed(
while True:
if await request.is_disconnected():
break
async with async_session_maker() as session:
async with async_session_maker() as stream_session:
if board_id is not None:
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
rows = await _fetch_task_comment_events(
stream_session,
last_seen,
board_id=board_id,
)
elif allowed_ids:
rows = await _fetch_task_comment_events(session, last_seen)
rows = await _fetch_task_comment_events(stream_session, last_seen)
rows = [row for row in rows if row[1].board_id in allowed_ids]
else:
rows = []
@@ -204,10 +235,16 @@ async def stream_task_comment_feed(
if len(seen_queue) > SSE_SEEN_MAX:
oldest = seen_queue.popleft()
seen_ids.discard(oldest)
if event.created_at > last_seen:
last_seen = event.created_at
payload = {"comment": _feed_item(event, task, board, agent).model_dump(mode="json")}
last_seen = max(event.created_at, last_seen)
payload = {
"comment": _feed_item(
event,
task,
board,
agent,
).model_dump(mode="json"),
}
yield {"event": "comment", "data": json.dumps(payload)}
await asyncio.sleep(2)
await asyncio.sleep(STREAM_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)

View File

@@ -1,15 +1,16 @@
"""Approval listing, streaming, creation, and update endpoints."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, case, func, or_
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import (
@@ -23,13 +24,32 @@ from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
from app.models.approvals import Approval
from app.models.boards import Board
from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalStatus, ApprovalUpdate
from app.schemas.approvals import (
ApprovalCreate,
ApprovalRead,
ApprovalStatus,
ApprovalUpdate,
)
from app.schemas.pagination import DefaultLimitOffsetPage
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"])
TASK_ID_KEYS: tuple[str, ...] = ("task_id", "taskId", "taskID")
STREAM_POLL_SECONDS = 2
STATUS_FILTER_QUERY = Query(default=None, alias="status")
SINCE_QUERY = Query(default=None)
BOARD_READ_DEP = Depends(get_board_for_actor_read)
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
SESSION_DEP = Depends(get_session)
ACTOR_DEP = Depends(require_admin_or_agent)
def _extract_task_id(payload: dict[str, object] | None) -> UUID | None:
@@ -68,7 +88,10 @@ def _approval_updated_at(approval: Approval) -> datetime:
def _serialize_approval(approval: Approval) -> dict[str, object]:
return ApprovalRead.model_validate(approval, from_attributes=True).model_dump(mode="json")
return ApprovalRead.model_validate(
approval,
from_attributes=True,
).model_dump(mode="json")
async def _fetch_approval_events(
@@ -82,7 +105,7 @@ async def _fetch_approval_events(
or_(
col(Approval.created_at) >= since,
col(Approval.resolved_at) >= since,
)
),
)
.order_by(asc(col(Approval.created_at)))
)
@@ -91,11 +114,12 @@ async def _fetch_approval_events(
@router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
async def list_approvals(
status_filter: ApprovalStatus | None = Query(default=None, alias="status"),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
status_filter: ApprovalStatus | None = STATUS_FILTER_QUERY,
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[ApprovalRead]:
"""List approvals for a board, optionally filtering by status."""
statement = Approval.objects.filter_by(board_id=board.id)
if status_filter:
statement = statement.filter(col(Approval.status) == status_filter)
@@ -106,10 +130,11 @@ async def list_approvals(
@router.get("/stream")
async def stream_approvals(
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 approval updates for a board using server-sent events."""
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -125,12 +150,14 @@ async def stream_approvals(
await session.exec(
select(func.count(col(Approval.id)))
.where(col(Approval.board_id) == board.id)
.where(col(Approval.status) == "pending")
.where(col(Approval.status) == "pending"),
)
).one()
).one(),
)
task_ids = {
approval.task_id for approval in approvals if approval.task_id is not None
approval.task_id
for approval in approvals
if approval.task_id is not None
}
counts_by_task_id: dict[UUID, tuple[int, int]] = {}
if task_ids:
@@ -140,22 +167,27 @@ async def stream_approvals(
col(Approval.task_id),
func.count(col(Approval.id)).label("total"),
func.sum(
case((col(Approval.status) == "pending", 1), else_=0)
case(
(col(Approval.status) == "pending", 1),
else_=0,
),
).label("pending"),
)
.where(col(Approval.board_id) == board.id)
.where(col(Approval.task_id).in_(task_ids))
.group_by(col(Approval.task_id))
)
.group_by(col(Approval.task_id)),
),
)
for task_id, total, pending in rows:
if task_id is None:
continue
counts_by_task_id[task_id] = (int(total or 0), int(pending or 0))
counts_by_task_id[task_id] = (
int(total or 0),
int(pending or 0),
)
for approval in approvals:
updated_at = _approval_updated_at(approval)
if updated_at > last_seen:
last_seen = updated_at
last_seen = max(updated_at, last_seen)
payload: dict[str, object] = {
"approval": _serialize_approval(approval),
"pending_approvals_count": pending_approvals_count,
@@ -170,7 +202,7 @@ async def stream_approvals(
"approvals_pending_count": pending,
}
yield {"event": "approval", "data": json.dumps(payload)}
await asyncio.sleep(2)
await asyncio.sleep(STREAM_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)
@@ -178,10 +210,11 @@ async def stream_approvals(
@router.post("", response_model=ApprovalRead)
async def create_approval(
payload: ApprovalCreate,
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = BOARD_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> Approval:
"""Create an approval for a board."""
task_id = payload.task_id or _extract_task_id(payload.payload)
approval = Approval(
board_id=board.id,
@@ -203,9 +236,10 @@ async def create_approval(
async def update_approval(
approval_id: str,
payload: ApprovalUpdate,
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
) -> Approval:
"""Update an approval's status and resolution timestamp."""
approval = await Approval.objects.by_id(approval_id).first(session)
if approval is None or approval.board_id != board.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -1,15 +1,17 @@
"""Board-group memory CRUD and streaming endpoints."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import func
from sqlmodel import col
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import (
@@ -24,28 +26,56 @@ from app.core.time import utcnow
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.agents import Agent
from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
from app.schemas.board_group_memory import (
BoardGroupMemoryCreate,
BoardGroupMemoryRead,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.organizations import (
OrganizationContext,
is_org_admin,
list_accessible_board_ids,
member_all_boards_read,
member_all_boards_write,
)
router = APIRouter(tags=["board-group-memory"])
if TYPE_CHECKING:
from collections.abc import AsyncIterator
group_router = APIRouter(prefix="/board-groups/{group_id}/memory", tags=["board-group-memory"])
board_router = APIRouter(prefix="/boards/{board_id}/group-memory", tags=["board-group-memory"])
from sqlmodel.ext.asyncio.session import AsyncSession
from app.services.organizations import OrganizationContext
router = APIRouter(tags=["board-group-memory"])
group_router = APIRouter(
prefix="/board-groups/{group_id}/memory",
tags=["board-group-memory"],
)
board_router = APIRouter(
prefix="/boards/{board_id}/group-memory",
tags=["board-group-memory"],
)
MAX_SNIPPET_LENGTH = 800
STREAM_POLL_SECONDS = 2
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
BOARD_READ_DEP = Depends(get_board_for_actor_read)
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
ACTOR_DEP = Depends(require_admin_or_agent)
IS_CHAT_QUERY = Query(default=None)
SINCE_QUERY = Query(default=None)
_RUNTIME_TYPE_REFERENCES = (UUID,)
def _parse_since(value: str | None) -> datetime | None:
@@ -65,10 +95,16 @@ def _parse_since(value: str | None) -> datetime | None:
def _serialize_memory(memory: BoardGroupMemory) -> dict[str, object]:
return BoardGroupMemoryRead.model_validate(memory, from_attributes=True).model_dump(mode="json")
return BoardGroupMemoryRead.model_validate(
memory,
from_attributes=True,
).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 board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
@@ -104,7 +140,7 @@ async def _fetch_memory_events(
if is_chat is not None:
statement = statement.filter(col(BoardGroupMemory.is_chat) == is_chat)
statement = statement.filter(col(BoardGroupMemory.created_at) >= since).order_by(
col(BoardGroupMemory.created_at)
col(BoardGroupMemory.created_at),
)
return await statement.all(session)
@@ -128,19 +164,124 @@ async def _require_group_access(
return group
board_ids = [
board.id for board in await Board.objects.filter_by(board_group_id=group_id).all(session)
board.id
for board in await Board.objects.filter_by(board_group_id=group_id).all(
session,
)
]
if not board_ids:
if is_org_admin(ctx.member):
return group
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_ids = await list_accessible_board_ids(session, member=ctx.member, write=write)
allowed_ids = await list_accessible_board_ids(
session,
member=ctx.member,
write=write,
)
if not set(board_ids).intersection(set(allowed_ids)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return group
async def _group_read_access(
group_id: UUID,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> BoardGroup:
return await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
GROUP_READ_DEP = Depends(_group_read_access)
def _group_chat_targets(
*,
agents: list[Agent],
actor: ActorContext,
is_broadcast: bool,
mentions: set[str],
) -> dict[str, Agent]:
targets: dict[str, Agent] = {}
for agent in agents:
if not agent.openclaw_session_id:
continue
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
continue
if is_broadcast or agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and matches_agent_mention(agent, mentions):
targets[str(agent.id)] = agent
return targets
def _group_actor_name(actor: ActorContext) -> str:
if actor.actor_type == "agent" and actor.agent:
return actor.agent.name
if actor.user:
return actor.user.preferred_name or actor.user.name or "User"
return "User"
def _group_header(*, is_broadcast: bool, mentioned: bool) -> str:
if is_broadcast:
return "GROUP BROADCAST"
if mentioned:
return "GROUP CHAT MENTION"
return "GROUP CHAT"
@dataclass(frozen=True)
class _NotifyGroupContext:
session: AsyncSession
group: BoardGroup
board_by_id: dict[UUID, Board]
mentions: set[str]
is_broadcast: bool
actor_name: str
snippet: str
base_url: str
async def _notify_group_target(
context: _NotifyGroupContext,
agent: Agent,
) -> None:
session_key = agent.openclaw_session_id
board_id = agent.board_id
if not session_key or board_id is None:
return
board = context.board_by_id.get(board_id)
if board is None:
return
config = await _gateway_config(context.session, board)
if config is None:
return
header = _group_header(
is_broadcast=context.is_broadcast,
mentioned=matches_agent_mention(agent, context.mentions),
)
message = (
f"{header}\n"
f"Group: {context.group.name}\n"
f"From: {context.actor_name}\n\n"
f"{context.snippet}\n\n"
"Reply via group chat (shared across linked boards):\n"
f"POST {context.base_url}/api/v1/boards/{board.id}/group-memory\n"
'Body: {"content":"...","tags":["chat"]}'
)
try:
await _send_agent_message(
session_key=session_key,
config=config,
agent_name=agent.name,
message=message,
)
except OpenClawGatewayError:
return
async def _notify_group_memory_targets(
*,
session: AsyncSession,
@@ -163,83 +304,47 @@ async def _notify_group_memory_targets(
board_ids = list(board_by_id.keys())
agents = await Agent.objects.by_field_in("board_id", board_ids).all(session)
targets: dict[str, Agent] = {}
for agent in agents:
if not agent.openclaw_session_id:
continue
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
continue
if is_broadcast:
targets[str(agent.id)] = agent
continue
if agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and matches_agent_mention(agent, mentions):
targets[str(agent.id)] = agent
targets = _group_chat_targets(
agents=agents,
actor=actor,
is_broadcast=is_broadcast,
mentions=mentions,
)
if not targets:
return
actor_name = "User"
if actor.actor_type == "agent" and actor.agent:
actor_name = actor.agent.name
elif actor.user:
actor_name = actor.user.preferred_name or actor.user.name or actor_name
actor_name = _group_actor_name(actor)
snippet = memory.content.strip()
if len(snippet) > 800:
snippet = f"{snippet[:797]}..."
if len(snippet) > MAX_SNIPPET_LENGTH:
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
base_url = settings.base_url or "http://localhost:8000"
context = _NotifyGroupContext(
session=session,
group=group,
board_by_id=board_by_id,
mentions=mentions,
is_broadcast=is_broadcast,
actor_name=actor_name,
snippet=snippet,
base_url=base_url,
)
for agent in targets.values():
session_key = agent.openclaw_session_id
if not session_key:
continue
board_id = agent.board_id
if board_id is None:
continue
board = board_by_id.get(board_id)
if board is None:
continue
config = await _gateway_config(session, board)
if config is None:
continue
mentioned = matches_agent_mention(agent, mentions)
if is_broadcast:
header = "GROUP BROADCAST"
elif mentioned:
header = "GROUP CHAT MENTION"
else:
header = "GROUP CHAT"
message = (
f"{header}\n"
f"Group: {group.name}\n"
f"From: {actor_name}\n\n"
f"{snippet}\n\n"
"Reply via group chat (shared across linked boards):\n"
f"POST {base_url}/api/v1/boards/{board.id}/group-memory\n"
'Body: {"content":"...","tags":["chat"]}'
)
try:
await _send_agent_message(
session_key=session_key,
config=config,
agent_name=agent.name,
message=message,
)
except OpenClawGatewayError:
continue
await _notify_group_target(context, agent)
@group_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
async def list_board_group_memory(
group_id: UUID,
is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
*,
is_chat: bool | None = IS_CHAT_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
"""List board-group memory entries for a specific group."""
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
statement = (
BoardGroupMemory.objects.filter_by(board_group_id=group_id)
@@ -255,14 +360,13 @@ async def list_board_group_memory(
@group_router.get("/stream")
async def stream_board_group_memory(
group_id: UUID,
request: Request,
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
group: BoardGroup = GROUP_READ_DEP,
*,
since: str | None = SINCE_QUERY,
is_chat: bool | None = IS_CHAT_QUERY,
) -> EventSourceResponse:
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
"""Stream memory entries for a board group via server-sent events."""
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -274,16 +378,15 @@ async def stream_board_group_memory(
async with async_session_maker() as s:
memories = await _fetch_memory_events(
s,
group_id,
group.id,
last_seen,
is_chat=is_chat,
)
for memory in memories:
if memory.created_at > last_seen:
last_seen = memory.created_at
last_seen = max(memory.created_at, last_seen)
payload = {"memory": _serialize_memory(memory)}
yield {"event": "memory", "data": json.dumps(payload)}
await asyncio.sleep(2)
await asyncio.sleep(STREAM_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)
@@ -292,9 +395,10 @@ async def stream_board_group_memory(
async def create_board_group_memory(
group_id: UUID,
payload: BoardGroupMemoryCreate,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> BoardGroupMemory:
"""Create a board-group memory entry and notify chat recipients."""
group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True)
user = await User.objects.by_id(ctx.member.user_id).first(session)
@@ -320,16 +424,23 @@ async def create_board_group_memory(
await session.commit()
await session.refresh(memory)
if should_notify:
await _notify_group_memory_targets(session=session, group=group, memory=memory, actor=actor)
await _notify_group_memory_targets(
session=session,
group=group,
memory=memory,
actor=actor,
)
return memory
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
async def list_board_group_memory_for_board(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
*,
is_chat: bool | None = IS_CHAT_QUERY,
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
"""List memory entries for the board's linked group."""
group_id = board.board_group_id
if group_id is None:
return await paginate(session, BoardGroupMemory.objects.by_ids([]).statement)
@@ -349,10 +460,12 @@ async def list_board_group_memory_for_board(
@board_router.get("/stream")
async def stream_board_group_memory_for_board(
request: Request,
board: Board = Depends(get_board_for_actor_read),
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
*,
board: Board = BOARD_READ_DEP,
since: str | None = SINCE_QUERY,
is_chat: bool | None = IS_CHAT_QUERY,
) -> EventSourceResponse:
"""Stream memory entries for the board's linked group."""
group_id = board.board_group_id
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -373,11 +486,10 @@ async def stream_board_group_memory_for_board(
is_chat=is_chat,
)
for memory in memories:
if memory.created_at > last_seen:
last_seen = memory.created_at
last_seen = max(memory.created_at, last_seen)
payload = {"memory": _serialize_memory(memory)}
yield {"event": "memory", "data": json.dumps(payload)}
await asyncio.sleep(2)
await asyncio.sleep(STREAM_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)
@@ -385,10 +497,11 @@ async def stream_board_group_memory_for_board(
@board_router.post("", response_model=BoardGroupMemoryRead)
async def create_board_group_memory_for_board(
payload: BoardGroupMemoryCreate,
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = BOARD_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> BoardGroupMemory:
"""Create a group memory entry from a board context and notify recipients."""
group_id = board.board_group_id
if group_id is None:
raise HTTPException(
@@ -420,7 +533,12 @@ async def create_board_group_memory_for_board(
await session.commit()
await session.refresh(memory)
if should_notify:
await _notify_group_memory_targets(session=session, group=group, memory=memory, actor=actor)
await _notify_group_memory_targets(
session=session,
group=group,
memory=memory,
actor=actor,
)
return memory

View File

@@ -1,15 +1,21 @@
"""Board group CRUD, snapshot, and heartbeat endpoints."""
from __future__ import annotations
import re
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
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 ActorContext, require_admin_or_agent, require_org_admin, require_org_member
from app.api.deps import (
ActorContext,
require_admin_or_agent,
require_org_admin,
require_org_member,
)
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
@@ -20,7 +26,6 @@ from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organization_members import OrganizationMember
from app.schemas.board_group_heartbeat import (
BoardGroupHeartbeatApply,
BoardGroupHeartbeatApplyResult,
@@ -29,7 +34,10 @@ from app.schemas.board_groups import BoardGroupCreate, BoardGroupRead, BoardGrou
from app.schemas.common import OkResponse
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
sync_gateway_agent_heartbeats,
)
from app.services.board_group_snapshot import build_group_snapshot
from app.services.organizations import (
OrganizationContext,
@@ -41,7 +49,16 @@ from app.services.organizations import (
member_all_boards_write,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.organization_members import OrganizationMember
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
ORG_ADMIN_DEP = Depends(require_org_admin)
ACTOR_DEP = Depends(require_admin_or_agent)
def _slugify(value: str) -> str:
@@ -68,7 +85,8 @@ async def _require_group_access(
return group
board_ids = [
board.id for board in await Board.objects.filter_by(board_group_id=group_id).all(session)
board.id
for board in await Board.objects.filter_by(board_group_id=group_id).all(session)
]
if not board_ids:
if is_org_admin(member):
@@ -83,14 +101,17 @@ async def _require_group_access(
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
async def list_board_groups(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DefaultLimitOffsetPage[BoardGroupRead]:
"""List board groups in the active organization."""
if member_all_boards_read(ctx.member):
statement = select(BoardGroup).where(col(BoardGroup.organization_id) == ctx.organization.id)
statement = select(BoardGroup).where(
col(BoardGroup.organization_id) == ctx.organization.id,
)
else:
accessible_boards = select(Board.board_group_id).where(
board_access_filter(ctx.member, write=False)
board_access_filter(ctx.member, write=False),
)
statement = select(BoardGroup).where(
col(BoardGroup.organization_id) == ctx.organization.id,
@@ -103,9 +124,10 @@ async def list_board_groups(
@router.post("", response_model=BoardGroupRead)
async def create_board_group(
payload: BoardGroupCreate,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> BoardGroup:
"""Create a board group in the active organization."""
data = payload.model_dump()
if not (data.get("slug") or "").strip():
data["slug"] = _slugify(data.get("name") or "")
@@ -116,21 +138,28 @@ async def create_board_group(
@router.get("/{group_id}", response_model=BoardGroupRead)
async def get_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> BoardGroup:
return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
"""Get a board group by id."""
return await _require_group_access(
session, group_id=group_id, member=ctx.member, write=False,
)
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
async def get_board_group_snapshot(
group_id: UUID,
*,
include_done: bool = False,
per_board_task_limit: int = 5,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> BoardGroupSnapshot:
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
"""Get a snapshot across boards in a group."""
group = await _require_group_access(
session, group_id=group_id, member=ctx.member, write=False,
)
if per_board_task_limit < 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
snapshot = await build_group_snapshot(
@@ -141,22 +170,22 @@ async def get_board_group_snapshot(
per_board_task_limit=per_board_task_limit,
)
if not member_all_boards_read(ctx.member) and snapshot.boards:
allowed_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=False))
snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids]
allowed_ids = set(
await list_accessible_board_ids(session, member=ctx.member, write=False),
)
snapshot.boards = [
item for item in snapshot.boards if item.board.id in allowed_ids
]
return snapshot
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
async def apply_board_group_heartbeat(
async def _authorize_heartbeat_actor(
session: AsyncSession,
*,
group_id: UUID,
payload: BoardGroupHeartbeatApply,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupHeartbeatApplyResult:
group = await BoardGroup.objects.by_id(group_id).first(session)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group: BoardGroup,
actor: ActorContext,
) -> None:
if actor.actor_type == "user":
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
@@ -173,53 +202,58 @@ async def apply_board_group_heartbeat(
member=member,
write=True,
)
elif actor.actor_type == "agent":
agent = actor.agent
if agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
if agent.board_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board = await Board.objects.by_id(agent.board_id).first(session)
if board is None or board.board_group_id != group_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return
agent = actor.agent
if agent is None or agent.board_id is None or not agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board = await Board.objects.by_id(agent.board_id).first(session)
if board is None or board.board_group_id != group_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
async def _agents_for_group_heartbeat(
session: AsyncSession,
*,
group_id: UUID,
include_board_leads: bool,
) -> tuple[dict[UUID, Board], list[Agent]]:
boards = await Board.objects.filter_by(board_group_id=group_id).all(session)
board_by_id = {board.id: board for board in boards}
board_ids = list(board_by_id.keys())
if not board_ids:
return BoardGroupHeartbeatApplyResult(
board_group_id=group_id,
requested=payload.model_dump(mode="json"),
updated_agent_ids=[],
failed_agent_ids=[],
)
return board_by_id, []
agents = await Agent.objects.by_field_in("board_id", board_ids).all(session)
if not payload.include_board_leads:
if not include_board_leads:
agents = [agent for agent in agents if not agent.is_board_lead]
return board_by_id, agents
updated_agent_ids: list[UUID] = []
for agent in agents:
raw = agent.heartbeat_config
heartbeat: dict[str, Any] = (
cast(dict[str, Any], dict(raw))
if isinstance(raw, dict)
else cast(dict[str, Any], DEFAULT_HEARTBEAT_CONFIG.copy())
)
heartbeat["every"] = payload.every
if payload.target is not None:
heartbeat["target"] = payload.target
elif "target" not in heartbeat:
heartbeat["target"] = DEFAULT_HEARTBEAT_CONFIG.get("target", "none")
agent.heartbeat_config = heartbeat
agent.updated_at = utcnow()
session.add(agent)
updated_agent_ids.append(agent.id)
await session.commit()
def _update_agent_heartbeat(
*,
agent: Agent,
payload: BoardGroupHeartbeatApply,
) -> None:
raw = agent.heartbeat_config
heartbeat: dict[str, Any] = (
cast(dict[str, Any], dict(raw))
if isinstance(raw, dict)
else cast(dict[str, Any], DEFAULT_HEARTBEAT_CONFIG.copy())
)
heartbeat["every"] = payload.every
if payload.target is not None:
heartbeat["target"] = payload.target
elif "target" not in heartbeat:
heartbeat["target"] = DEFAULT_HEARTBEAT_CONFIG.get("target", "none")
agent.heartbeat_config = heartbeat
agent.updated_at = utcnow()
async def _sync_gateway_heartbeats(
session: AsyncSession,
*,
board_by_id: dict[UUID, Board],
agents: list[Agent],
) -> list[UUID]:
agents_by_gateway_id: dict[UUID, list[Agent]] = {}
for agent in agents:
board_id = agent.board_id
@@ -243,6 +277,51 @@ async def apply_board_group_heartbeat(
await sync_gateway_agent_heartbeats(gateway, gateway_agents)
except OpenClawGatewayError:
failed_agent_ids.extend([agent.id for agent in gateway_agents])
return failed_agent_ids
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
async def apply_board_group_heartbeat(
group_id: UUID,
payload: BoardGroupHeartbeatApply,
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> BoardGroupHeartbeatApplyResult:
"""Apply heartbeat settings to agents in a board group."""
group = await BoardGroup.objects.by_id(group_id).first(session)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _authorize_heartbeat_actor(
session,
group_id=group_id,
group=group,
actor=actor,
)
board_by_id, agents = await _agents_for_group_heartbeat(
session,
group_id=group_id,
include_board_leads=payload.include_board_leads,
)
if not agents:
return BoardGroupHeartbeatApplyResult(
board_group_id=group_id,
requested=payload.model_dump(mode="json"),
updated_agent_ids=[],
failed_agent_ids=[],
)
updated_agent_ids: list[UUID] = []
for agent in agents:
_update_agent_heartbeat(agent=agent, payload=payload)
session.add(agent)
updated_agent_ids.append(agent.id)
await session.commit()
failed_agent_ids = await _sync_gateway_heartbeats(
session,
board_by_id=board_by_id,
agents=agents,
)
return BoardGroupHeartbeatApplyResult(
board_group_id=group_id,
@@ -256,12 +335,19 @@ async def apply_board_group_heartbeat(
async def update_board_group(
payload: BoardGroupUpdate,
group_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> BoardGroup:
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
"""Update a board group."""
group = await _require_group_access(
session, group_id=group_id, member=ctx.member, write=True,
)
updates = payload.model_dump(exclude_unset=True)
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
if (
"slug" in updates
and updates["slug"] is not None
and not updates["slug"].strip()
):
updates["slug"] = _slugify(updates.get("name") or group.name)
updates["updated_at"] = utcnow()
return await crud.patch(session, group, updates)
@@ -270,10 +356,13 @@ async def update_board_group(
@router.delete("/{group_id}", response_model=OkResponse)
async def delete_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
"""Delete a board group."""
await _require_group_access(
session, group_id=group_id, member=ctx.member, write=True,
)
# Boards reference groups, so clear the FK first to keep deletes simple.
await crud.update_where(
@@ -284,8 +373,13 @@ async def delete_board_group(
commit=False,
)
await crud.delete_where(
session, BoardGroupMemory, col(BoardGroupMemory.board_group_id) == group_id, commit=False
session,
BoardGroupMemory,
col(BoardGroupMemory.board_group_id) == group_id,
commit=False,
)
await crud.delete_where(
session, BoardGroup, col(BoardGroup.id) == group_id, commit=False,
)
await crud.delete_where(session, BoardGroup, col(BoardGroup.id) == group_id, commit=False)
await session.commit()
return OkResponse()

View File

@@ -1,15 +1,16 @@
"""Board memory CRUD and streaming endpoints."""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func
from sqlmodel import col
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import (
@@ -23,16 +24,35 @@ from app.core.time import utcnow
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.agents import Agent
from app.models.board_memory import BoardMemory
from app.models.boards import Board
from app.models.gateways import Gateway
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.mentions import extract_mentions, matches_agent_mention
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.boards import Board
router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"])
MAX_SNIPPET_LENGTH = 800
STREAM_POLL_SECONDS = 2
IS_CHAT_QUERY = Query(default=None)
SINCE_QUERY = Query(default=None)
BOARD_READ_DEP = Depends(get_board_for_actor_read)
BOARD_WRITE_DEP = Depends(get_board_for_actor_write)
SESSION_DEP = Depends(get_session)
ACTOR_DEP = Depends(require_admin_or_agent)
_RUNTIME_TYPE_REFERENCES = (UUID,)
def _parse_since(value: str | None) -> datetime | None:
@@ -52,10 +72,16 @@ def _parse_since(value: str | None) -> datetime | None:
def _serialize_memory(memory: BoardMemory) -> dict[str, object]:
return BoardMemoryRead.model_validate(memory, from_attributes=True).model_dump(mode="json")
return BoardMemoryRead.model_validate(
memory,
from_attributes=True,
).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 board.gateway_id is None:
return None
gateway = await Gateway.objects.by_id(board.gateway_id).first(session)
@@ -91,11 +117,67 @@ async def _fetch_memory_events(
if is_chat is not None:
statement = statement.filter(col(BoardMemory.is_chat) == is_chat)
statement = statement.filter(col(BoardMemory.created_at) >= since).order_by(
col(BoardMemory.created_at)
col(BoardMemory.created_at),
)
return await statement.all(session)
async def _send_control_command(
*,
session: AsyncSession,
board: Board,
actor: ActorContext,
config: GatewayClientConfig,
command: str,
) -> None:
pause_targets: list[Agent] = await Agent.objects.filter_by(
board_id=board.id,
).all(
session,
)
for agent in pause_targets:
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
continue
if not agent.openclaw_session_id:
continue
try:
await _send_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=command,
deliver=True,
)
except OpenClawGatewayError:
continue
def _chat_targets(
*,
agents: list[Agent],
mentions: set[str],
actor: ActorContext,
) -> dict[str, Agent]:
targets: dict[str, Agent] = {}
for agent in agents:
if agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and matches_agent_mention(agent, mentions):
targets[str(agent.id)] = agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(str(actor.agent.id), None)
return targets
def _actor_display_name(actor: ActorContext) -> str:
if actor.actor_type == "agent" and actor.agent:
return actor.agent.name
if actor.user:
return actor.user.preferred_name or actor.user.name or "User"
return "User"
async def _notify_chat_targets(
*,
session: AsyncSession,
@@ -114,44 +196,27 @@ async def _notify_chat_targets(
# Special-case control commands to reach all board agents.
# These are intended to be parsed verbatim by agent runtimes.
if command in {"/pause", "/resume"}:
pause_targets: list[Agent] = await Agent.objects.filter_by(board_id=board.id).all(session)
for agent in pause_targets:
if actor.actor_type == "agent" and actor.agent and agent.id == actor.agent.id:
continue
if not agent.openclaw_session_id:
continue
try:
await _send_agent_message(
session_key=agent.openclaw_session_id,
config=config,
agent_name=agent.name,
message=command,
deliver=True,
)
except OpenClawGatewayError:
continue
await _send_control_command(
session=session,
board=board,
actor=actor,
config=config,
command=command,
)
return
mentions = extract_mentions(memory.content)
targets: dict[str, Agent] = {}
for agent in await Agent.objects.filter_by(board_id=board.id).all(session):
if agent.is_board_lead:
targets[str(agent.id)] = agent
continue
if mentions and matches_agent_mention(agent, mentions):
targets[str(agent.id)] = agent
if actor.actor_type == "agent" and actor.agent:
targets.pop(str(actor.agent.id), None)
targets = _chat_targets(
agents=await Agent.objects.filter_by(board_id=board.id).all(session),
mentions=mentions,
actor=actor,
)
if not targets:
return
actor_name = "User"
if actor.actor_type == "agent" and actor.agent:
actor_name = actor.agent.name
elif actor.user:
actor_name = actor.user.preferred_name or actor.user.name or actor_name
actor_name = _actor_display_name(actor)
snippet = memory.content.strip()
if len(snippet) > 800:
snippet = f"{snippet[:797]}..."
if len(snippet) > MAX_SNIPPET_LENGTH:
snippet = f"{snippet[: MAX_SNIPPET_LENGTH - 3]}..."
base_url = settings.base_url or "http://localhost:8000"
for agent in targets.values():
if not agent.openclaw_session_id:
@@ -180,11 +245,13 @@ async def _notify_chat_targets(
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
async def list_board_memory(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
*,
is_chat: bool | None = IS_CHAT_QUERY,
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
_actor: ActorContext = ACTOR_DEP,
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
"""List board memory entries, optionally filtering chat entries."""
statement = (
BoardMemory.objects.filter_by(board_id=board.id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
@@ -200,11 +267,13 @@ async def list_board_memory(
@router.get("/stream")
async def stream_board_memory(
request: Request,
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
*,
board: Board = BOARD_READ_DEP,
_actor: ActorContext = ACTOR_DEP,
since: str | None = SINCE_QUERY,
is_chat: bool | None = IS_CHAT_QUERY,
) -> EventSourceResponse:
"""Stream board memory events over server-sent events."""
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -221,11 +290,10 @@ async def stream_board_memory(
is_chat=is_chat,
)
for memory in memories:
if memory.created_at > last_seen:
last_seen = memory.created_at
last_seen = max(memory.created_at, last_seen)
payload = {"memory": _serialize_memory(memory)}
yield {"event": "memory", "data": json.dumps(payload)}
await asyncio.sleep(2)
await asyncio.sleep(STREAM_POLL_SECONDS)
return EventSourceResponse(event_generator(), ping=15)
@@ -233,10 +301,11 @@ async def stream_board_memory(
@router.post("", response_model=BoardMemoryRead)
async def create_board_memory(
payload: BoardMemoryCreate,
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = BOARD_WRITE_DEP,
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> BoardMemory:
"""Create a board memory entry and notify chat targets when needed."""
is_chat = payload.tags is not None and "chat" in payload.tags
source = payload.source
if is_chat and not source:
@@ -255,5 +324,10 @@ async def create_board_memory(
await session.commit()
await session.refresh(memory)
if is_chat:
await _notify_chat_targets(session=session, board=board, memory=memory, actor=actor)
await _notify_chat_targets(
session=session,
board=board,
memory=memory,
actor=actor,
)
return memory

View File

@@ -1,5 +1,4 @@
"""Board onboarding endpoints for user/agent collaboration."""
# ruff: noqa: E501
from __future__ import annotations
@@ -201,16 +200,22 @@ async def start_onboarding(
f"Board Name: {board.name}\n"
"You are the main agent. Ask the user 6-10 focused questions total:\n"
"- 3-6 questions to clarify the board goal.\n"
"- 1 question to choose a unique name for the board lead agent (first-name style).\n"
"- 2-4 questions to capture the user's preferences for how the board lead should work\n"
"- 1 question to choose a unique name for the board lead agent "
"(first-name style).\n"
"- 2-4 questions to capture the user's preferences for how the board "
"lead should work\n"
" (communication style, autonomy, update cadence, and output formatting).\n"
'- Always include a final question (and only once): "Anything else we should know?"\n'
'- Always include a final question (and only once): "Anything else we '
'should know?"\n'
" (constraints, context, preferences). This MUST be the last question.\n"
' Provide an option like "Yes (I\'ll type it)" so they can enter free-text.\n'
" Do NOT ask for additional context on earlier questions.\n"
" Only include a free-text option on earlier questions if a typed answer is necessary;\n"
' when you do, make the option label include "I\'ll type it" (e.g., "Other (I\'ll type it)").\n'
'- If the user sends an "Additional context" message later, incorporate it and resend status=complete\n'
" Only include a free-text option on earlier questions if a typed "
"answer is necessary;\n"
' when you do, make the option label include "I\'ll type it" '
'(e.g., "Other (I\'ll type it)").\n'
'- If the user sends an "Additional context" message later, incorporate '
"it and resend status=complete\n"
" to update the draft (until the user confirms).\n"
"Do NOT respond in OpenClaw chat.\n"
"All onboarding responses MUST be sent to Mission Control via API.\n"
@@ -222,24 +227,37 @@ async def start_onboarding(
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
'-H "X-Agent-Token: $AUTH_TOKEN" '
'-H "Content-Type: application/json" '
'-d \'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\'\n'
'-d \'{"question":"...","options":[{"id":"1","label":"..."},'
'{"id":"2","label":"..."}]}\'\n'
"COMPLETION example (send JSON body exactly as shown):\n"
f'curl -s -X POST "{base_url}/api/v1/agent/boards/{board.id}/onboarding" '
'-H "X-Agent-Token: $AUTH_TOKEN" '
'-H "Content-Type: application/json" '
'-d \'{"status":"complete","board_type":"goal","objective":"...","success_metrics":{"metric":"...","target":"..."},"target_date":"YYYY-MM-DD","user_profile":{"preferred_name":"...","pronouns":"...","timezone":"...","notes":"...","context":"..."},"lead_agent":{"name":"Ava","identity_profile":{"role":"Board Lead","communication_style":"direct, concise, practical","emoji":":gear:"},"autonomy_level":"balanced","verbosity":"concise","output_format":"bullets","update_cadence":"daily","custom_instructions":"..."}}\'\n'
'-d \'{"status":"complete","board_type":"goal","objective":"...",'
'"success_metrics":{"metric":"...","target":"..."},'
'"target_date":"YYYY-MM-DD",'
'"user_profile":{"preferred_name":"...","pronouns":"...",'
'"timezone":"...","notes":"...","context":"..."},'
'"lead_agent":{"name":"Ava","identity_profile":{"role":"Board Lead",'
'"communication_style":"direct, concise, practical","emoji":":gear:"},'
'"autonomy_level":"balanced","verbosity":"concise",'
'"output_format":"bullets","update_cadence":"daily",'
'"custom_instructions":"..."}}\'\n'
"ENUMS:\n"
"- board_type: goal | general\n"
"- lead_agent.autonomy_level: ask_first | balanced | autonomous\n"
"- lead_agent.verbosity: concise | balanced | detailed\n"
"- lead_agent.output_format: bullets | mixed | narrative\n"
"- lead_agent.update_cadence: asap | hourly | daily | weekly\n"
"QUESTION FORMAT (one question per response, no arrays, no markdown, no extra text):\n"
"QUESTION FORMAT (one question per response, no arrays, no markdown, "
"no extra text):\n"
'{"question":"...","options":[{"id":"1","label":"..."},{"id":"2","label":"..."}]}\n'
"Do NOT wrap questions in a list. Do NOT add commentary.\n"
"When you have enough info, send one final response with status=complete.\n"
"The completion payload must include board_type. If board_type=goal, include objective + success_metrics.\n"
"Also include user_profile + lead_agent to configure the board lead's working style.\n"
"The completion payload must include board_type. If board_type=goal, "
"include objective + success_metrics.\n"
"Also include user_profile + lead_agent to configure the board lead's "
"working style.\n"
)
try:

View File

@@ -1,19 +1,18 @@
"""Reusable FastAPI dependencies for auth and board/task access."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from typing import TYPE_CHECKING, Literal
from fastapi import Depends, HTTPException, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context_optional
from app.core.auth import AuthContext, get_auth_context, get_auth_context_optional
from app.db.session import get_session
from app.models.agents import Agent
from app.models.boards import Board
from app.models.organizations import Organization
from app.models.tasks import Task
from app.models.users import User
from app.services.admin_access import require_admin
from app.services.organizations import (
OrganizationContext,
@@ -23,23 +22,38 @@ from app.services.organizations import (
require_board_access,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthContext:
from app.models.agents import Agent
from app.models.users import User
AUTH_DEP = Depends(get_auth_context)
AUTH_OPTIONAL_DEP = Depends(get_auth_context_optional)
AGENT_AUTH_OPTIONAL_DEP = Depends(get_agent_auth_context_optional)
SESSION_DEP = Depends(get_session)
def require_admin_auth(auth: AuthContext = AUTH_DEP) -> AuthContext:
"""Require an authenticated admin user."""
require_admin(auth)
return auth
@dataclass
class ActorContext:
"""Authenticated actor context for user or agent callers."""
actor_type: Literal["user", "agent"]
user: User | None = None
agent: Agent | None = None
def require_admin_or_agent(
auth: AuthContext | None = Depends(get_auth_context_optional),
agent_auth: AgentAuthContext | None = Depends(get_agent_auth_context_optional),
auth: AuthContext | None = AUTH_OPTIONAL_DEP,
agent_auth: AgentAuthContext | None = AGENT_AUTH_OPTIONAL_DEP,
) -> ActorContext:
"""Authorize either an admin user or an authenticated agent."""
if auth is not None:
require_admin(auth)
return ActorContext(actor_type="user", user=auth.user)
@@ -48,10 +62,14 @@ def require_admin_or_agent(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
ACTOR_DEP = Depends(require_admin_or_agent)
async def require_org_member(
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_session),
auth: AuthContext = AUTH_DEP,
session: AsyncSession = SESSION_DEP,
) -> OrganizationContext:
"""Resolve and require active organization membership for the current user."""
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, auth.user)
@@ -59,15 +77,21 @@ async def require_org_member(
member = await ensure_member_for_user(session, auth.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)
ORG_MEMBER_DEP = Depends(require_org_member)
async def require_org_admin(
ctx: OrganizationContext = Depends(require_org_member),
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> OrganizationContext:
"""Require organization-admin membership privileges."""
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return ctx
@@ -75,8 +99,9 @@ async def require_org_admin(
async def get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
session: AsyncSession = SESSION_DEP,
) -> Board:
"""Load a board by id or raise HTTP 404."""
board = await Board.objects.by_id(board_id).first(session)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -85,9 +110,10 @@ async def get_board_or_404(
async def get_board_for_actor_read(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> Board:
"""Load a board and enforce actor read access."""
board = await Board.objects.by_id(board_id).first(session)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -103,9 +129,10 @@ async def get_board_for_actor_read(
async def get_board_for_actor_write(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
session: AsyncSession = SESSION_DEP,
actor: ActorContext = ACTOR_DEP,
) -> Board:
"""Load a board and enforce actor write access."""
board = await Board.objects.by_id(board_id).first(session)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -121,9 +148,10 @@ async def get_board_for_actor_write(
async def get_board_for_user_read(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
) -> Board:
"""Load a board and enforce authenticated-user read access."""
board = await Board.objects.by_id(board_id).first(session)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -135,9 +163,10 @@ async def get_board_for_user_read(
async def get_board_for_user_write(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
) -> Board:
"""Load a board and enforce authenticated-user write access."""
board = await Board.objects.by_id(board_id).first(session)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
@@ -147,11 +176,15 @@ async def get_board_for_user_write(
return board
BOARD_READ_DEP = Depends(get_board_for_actor_read)
async def get_task_or_404(
task_id: str,
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
board: Board = BOARD_READ_DEP,
session: AsyncSession = SESSION_DEP,
) -> Task:
"""Load a task for a board or raise HTTP 404."""
task = await Task.objects.by_id(task_id).first(session)
if task is None or task.board_id != board.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

View File

@@ -1,7 +1,10 @@
"""Gateway inspection and session-management endpoints."""
from __future__ import annotations
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.core.auth import AuthContext, get_auth_context
@@ -21,7 +24,6 @@ from app.integrations.openclaw_gateway_protocol import (
)
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.common import OkResponse
from app.schemas.gateway_api import (
GatewayCommandsResponse,
@@ -34,32 +36,48 @@ from app.schemas.gateway_api import (
)
from app.services.organizations import OrganizationContext, require_board_access
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.users import User
router = APIRouter(prefix="/gateways", tags=["gateways"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
BOARD_ID_QUERY = Query(default=None)
RESOLVE_QUERY_DEP = Depends()
def _query_to_resolve_input(params: GatewayResolveQuery) -> GatewayResolveQuery:
return params
RESOLVE_INPUT_DEP = Depends(_query_to_resolve_input)
async def _resolve_gateway(
session: AsyncSession,
board_id: str | None,
gateway_url: str | None,
gateway_token: str | None,
gateway_main_session_key: str | None,
params: GatewayResolveQuery,
*,
user: User | None = None,
) -> tuple[Board | None, GatewayClientConfig, str | None]:
if gateway_url:
if params.gateway_url:
return (
None,
GatewayClientConfig(url=gateway_url, token=gateway_token),
gateway_main_session_key,
GatewayClientConfig(url=params.gateway_url, token=params.gateway_token),
params.gateway_main_session_key,
)
if not board_id:
if not params.board_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_id or gateway_url is required",
)
board = await Board.objects.by_id(board_id).first(session)
board = await Board.objects.by_id(params.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=False)
if not board.gateway_id:
@@ -86,14 +104,12 @@ async def _resolve_gateway(
async def _require_gateway(
session: AsyncSession, board_id: str | None, *, user: User | None = None
session: AsyncSession, board_id: str | None, *, user: User | None = None,
) -> tuple[Board, GatewayClientConfig, str | None]:
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
board_id,
None,
None,
None,
params,
user=user,
)
if board is None:
@@ -106,17 +122,15 @@ async def _require_gateway(
@router.get("/status", response_model=GatewaysStatusResponse)
async def gateways_status(
params: GatewayResolveQuery = Depends(),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
params: GatewayResolveQuery = RESOLVE_INPUT_DEP,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaysStatusResponse:
"""Return gateway connectivity and session status."""
board, config, main_session = await _resolve_gateway(
session,
params.board_id,
params.gateway_url,
params.gateway_token,
params.gateway_main_session_key,
params,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
@@ -131,7 +145,9 @@ async def gateways_status(
main_session_error: str | None = None
if main_session:
try:
ensured = await ensure_session(main_session, config=config, label="Main Agent")
ensured = await ensure_session(
main_session, config=config, label="Main Agent",
)
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError as exc:
@@ -146,22 +162,23 @@ async def gateways_status(
main_session_error=main_session_error,
)
except OpenClawGatewayError as exc:
return GatewaysStatusResponse(connected=False, gateway_url=config.url, error=str(exc))
return GatewaysStatusResponse(
connected=False, gateway_url=config.url, error=str(exc),
)
@router.get("/sessions", response_model=GatewaySessionsResponse)
async def list_gateway_sessions(
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
board_id: str | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionsResponse:
"""List sessions for a gateway associated with a board."""
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
board_id,
None,
None,
None,
params,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
@@ -169,7 +186,9 @@ async def list_gateway_sessions(
try:
sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
) from exc
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
@@ -178,7 +197,9 @@ async def list_gateway_sessions(
main_session_entry: object | None = None
if main_session:
try:
ensured = await ensure_session(main_session, config=config, label="Main Agent")
ensured = await ensure_session(
main_session, config=config, label="Main Agent",
)
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError:
@@ -191,70 +212,103 @@ async def list_gateway_sessions(
)
async def _list_sessions(config: GatewayClientConfig) -> list[dict[str, object]]:
sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict):
raw_items = sessions.get("sessions") or []
else:
raw_items = sessions or []
return [
item
for item in raw_items
if isinstance(item, dict)
]
async def _with_main_session(
sessions_list: list[dict[str, object]],
*,
config: GatewayClientConfig,
main_session: str | None,
) -> list[dict[str, object]]:
if not main_session or any(
item.get("key") == main_session for item in sessions_list
):
return sessions_list
try:
await ensure_session(main_session, config=config, label="Main Agent")
return await _list_sessions(config)
except OpenClawGatewayError:
return sessions_list
@router.get("/sessions/{session_id}", response_model=GatewaySessionResponse)
async def get_gateway_session(
session_id: str,
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
board_id: str | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionResponse:
"""Get a specific gateway session by key."""
params = GatewayResolveQuery(board_id=board_id)
board, config, main_session = await _resolve_gateway(
session,
board_id,
None,
None,
None,
params,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions = await openclaw_call("sessions.list", config=config)
sessions_list = await _list_sessions(config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
if main_session and not any(item.get("key") == main_session for item in sessions_list):
try:
await ensure_session(main_session, config=config, label="Main Agent")
refreshed = await openclaw_call("sessions.list", config=config)
if isinstance(refreshed, dict):
sessions_list = list(refreshed.get("sessions") or [])
else:
sessions_list = list(refreshed or [])
except OpenClawGatewayError:
pass
session_entry = next((item for item in sessions_list if item.get("key") == session_id), None)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
) from exc
sessions_list = await _with_main_session(
sessions_list,
config=config,
main_session=main_session,
)
session_entry = next(
(item for item in sessions_list if item.get("key") == session_id), None,
)
if session_entry is None and main_session and session_id == main_session:
try:
ensured = await ensure_session(main_session, config=config, label="Main Agent")
ensured = await ensure_session(
main_session, config=config, label="Main Agent",
)
if isinstance(ensured, dict):
session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError:
session_entry = None
if session_entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found",
)
return GatewaySessionResponse(session=session_entry)
@router.get("/sessions/{session_id}/history", response_model=GatewaySessionHistoryResponse)
@router.get(
"/sessions/{session_id}/history", response_model=GatewaySessionHistoryResponse,
)
async def get_session_history(
session_id: str,
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
board_id: str | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewaySessionHistoryResponse:
"""Fetch chat history for a gateway session."""
board, config, _ = await _require_gateway(session, board_id, user=auth.user)
if board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
history = await get_chat_history(session_id, config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
) from exc
if isinstance(history, dict) and isinstance(history.get("messages"), list):
return GatewaySessionHistoryResponse(history=history["messages"])
return GatewaySessionHistoryResponse(history=list(history or []))
@@ -264,14 +318,14 @@ async def get_session_history(
async def send_gateway_session_message(
session_id: str,
payload: GatewaySessionMessageRequest,
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
board_id: str | None = BOARD_ID_QUERY,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
) -> OkResponse:
board, config, main_session = await _require_gateway(session, board_id, user=auth.user)
if board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
"""Send a message into a specific gateway session."""
board, config, main_session = await _require_gateway(
session, board_id, user=auth.user,
)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
@@ -280,15 +334,18 @@ async def send_gateway_session_message(
await ensure_session(main_session, config=config, label="Main Agent")
await send_message(payload.content, session_key=session_id, config=config)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc),
) from exc
return OkResponse()
@router.get("/commands", response_model=GatewayCommandsResponse)
async def gateway_commands(
auth: AuthContext = Depends(get_auth_context),
_ctx: OrganizationContext = Depends(require_org_admin),
_auth: AuthContext = AUTH_DEP,
_ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayCommandsResponse:
"""Return supported gateway protocol methods and events."""
return GatewayCommandsResponse(
protocol_version=PROTOCOL_VERSION,
methods=GATEWAY_METHODS,

View File

@@ -1,10 +1,13 @@
"""Gateway CRUD and template synchronization endpoints."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
@@ -14,7 +17,11 @@ from app.db import crud
from app.db.pagination import paginate
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.gateways import Gateway
from app.schemas.common import OkResponse
@@ -25,11 +32,61 @@ from app.schemas.gateways import (
GatewayUpdate,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_main_agent
from app.services.organizations import OrganizationContext
from app.services.template_sync import sync_gateway_templates as sync_gateway_templates_service
from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
provision_main_agent,
)
from app.services.template_sync import (
GatewayTemplateSyncOptions,
)
from app.services.template_sync import (
sync_gateway_templates as sync_gateway_templates_service,
)
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.services.organizations import OrganizationContext
router = APIRouter(prefix="/gateways", tags=["gateways"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
INCLUDE_MAIN_QUERY = Query(default=True)
RESET_SESSIONS_QUERY = Query(default=False)
ROTATE_TOKENS_QUERY = Query(default=False)
FORCE_BOOTSTRAP_QUERY = Query(default=False)
BOARD_ID_QUERY = Query(default=None)
_RUNTIME_TYPE_REFERENCES = (UUID,)
@dataclass(frozen=True)
class _TemplateSyncQuery:
include_main: bool
reset_sessions: bool
rotate_tokens: bool
force_bootstrap: bool
board_id: UUID | None
def _template_sync_query(
*,
include_main: bool = INCLUDE_MAIN_QUERY,
reset_sessions: bool = RESET_SESSIONS_QUERY,
rotate_tokens: bool = ROTATE_TOKENS_QUERY,
force_bootstrap: bool = FORCE_BOOTSTRAP_QUERY,
board_id: UUID | None = BOARD_ID_QUERY,
) -> _TemplateSyncQuery:
return _TemplateSyncQuery(
include_main=include_main,
reset_sessions=reset_sessions,
rotate_tokens=rotate_tokens,
force_bootstrap=force_bootstrap,
board_id=board_id,
)
SYNC_QUERY_DEP = Depends(_template_sync_query)
def _main_agent_name(gateway: Gateway) -> str:
@@ -48,7 +105,9 @@ async def _require_gateway(
.first(session)
)
if gateway is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found",
)
return gateway
@@ -59,14 +118,18 @@ async def _find_main_agent(
previous_session_key: str | None = None,
) -> Agent | None:
if gateway.main_session_key:
agent = await Agent.objects.filter_by(openclaw_session_id=gateway.main_session_key).first(
session
agent = await Agent.objects.filter_by(
openclaw_session_id=gateway.main_session_key,
).first(
session,
)
if agent:
return agent
if previous_session_key:
agent = await Agent.objects.filter_by(openclaw_session_id=previous_session_key).first(
session
agent = await Agent.objects.filter_by(
openclaw_session_id=previous_session_key,
).first(
session,
)
if agent:
return agent
@@ -85,13 +148,17 @@ async def _ensure_main_agent(
gateway: Gateway,
auth: AuthContext,
*,
previous_name: str | None = None,
previous_session_key: str | None = None,
previous: tuple[str | None, str | None] | None = None,
action: str = "provision",
) -> Agent | None:
if not gateway.url or not gateway.main_session_key:
return None
agent = await _find_main_agent(session, gateway, previous_name, previous_session_key)
agent = await _find_main_agent(
session,
gateway,
previous_name=previous[0] if previous else None,
previous_session_key=previous[1] if previous else None,
)
if agent is None:
agent = Agent(
name=_main_agent_name(gateway),
@@ -130,7 +197,8 @@ async def _ensure_main_agent(
(
f"Hello {agent.name}. Your gateway provisioning was updated.\n\n"
"Please re-read AGENTS.md, USER.md, HEARTBEAT.md, and TOOLS.md. "
"If BOOTSTRAP.md exists, run it once then delete it. Begin heartbeats after startup."
"If BOOTSTRAP.md exists, run it once then delete it. "
"Begin heartbeats after startup."
),
session_key=gateway.main_session_key,
config=GatewayClientConfig(url=gateway.url, token=gateway.token),
@@ -144,9 +212,10 @@ async def _ensure_main_agent(
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
async def list_gateways(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> DefaultLimitOffsetPage[GatewayRead]:
"""List gateways for the caller's organization."""
statement = (
Gateway.objects.filter_by(organization_id=ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
@@ -158,10 +227,11 @@ async def list_gateways(
@router.post("", response_model=GatewayRead)
async def create_gateway(
payload: GatewayCreate,
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,
) -> Gateway:
"""Create a gateway and provision or refresh its main agent."""
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
gateway = await crud.create(session, Gateway, **data)
@@ -172,9 +242,10 @@ async def create_gateway(
@router.get("/{gateway_id}", response_model=GatewayRead)
async def get_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> Gateway:
"""Return one gateway by id for the caller's organization."""
return await _require_gateway(
session,
gateway_id=gateway_id,
@@ -186,10 +257,11 @@ async def get_gateway(
async def update_gateway(
gateway_id: UUID,
payload: GatewayUpdate,
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,
) -> Gateway:
"""Patch a gateway and refresh the main-agent provisioning state."""
gateway = await _require_gateway(
session,
gateway_id=gateway_id,
@@ -203,8 +275,7 @@ async def update_gateway(
session,
gateway,
auth,
previous_name=previous_name,
previous_session_key=previous_session_key,
previous=(previous_name, previous_session_key),
action="update",
)
return gateway
@@ -213,15 +284,12 @@ async def update_gateway(
@router.post("/{gateway_id}/templates/sync", response_model=GatewayTemplatesSyncResult)
async def sync_gateway_templates(
gateway_id: UUID,
include_main: bool = Query(default=True),
reset_sessions: bool = Query(default=False),
rotate_tokens: bool = Query(default=False),
force_bootstrap: bool = Query(default=False),
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
sync_query: _TemplateSyncQuery = SYNC_QUERY_DEP,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> GatewayTemplatesSyncResult:
"""Sync templates for a gateway and optionally rotate runtime settings."""
gateway = await _require_gateway(
session,
gateway_id=gateway_id,
@@ -230,21 +298,24 @@ async def sync_gateway_templates(
return await sync_gateway_templates_service(
session,
gateway,
user=auth.user,
include_main=include_main,
reset_sessions=reset_sessions,
rotate_tokens=rotate_tokens,
force_bootstrap=force_bootstrap,
board_id=board_id,
GatewayTemplateSyncOptions(
user=auth.user,
include_main=sync_query.include_main,
reset_sessions=sync_query.reset_sessions,
rotate_tokens=sync_query.rotate_tokens,
force_bootstrap=sync_query.force_bootstrap,
board_id=sync_query.board_id,
),
)
@router.delete("/{gateway_id}", response_model=OkResponse)
async def delete_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> OkResponse:
"""Delete a gateway in the caller's organization."""
gateway = await _require_gateway(
session,
gateway_id=gateway_id,

View File

@@ -1,3 +1,5 @@
"""Dashboard metric aggregation endpoints."""
from __future__ import annotations
from dataclasses import dataclass
@@ -32,10 +34,16 @@ router = APIRouter(prefix="/metrics", tags=["metrics"])
OFFLINE_AFTER = timedelta(minutes=10)
ERROR_EVENT_PATTERN = "%failed"
_RUNTIME_TYPE_REFERENCES = (UUID, AsyncSession)
RANGE_QUERY = Query(default="24h")
SESSION_DEP = Depends(get_session)
ORG_MEMBER_DEP = Depends(require_org_member)
@dataclass(frozen=True)
class RangeSpec:
"""Resolved time-range specification for metric aggregation."""
key: Literal["24h", "7d"]
start: datetime
end: datetime
@@ -80,7 +88,8 @@ def _build_buckets(range_spec: RangeSpec) -> list[datetime]:
def _series_from_mapping(
range_spec: RangeSpec, mapping: dict[datetime, float]
range_spec: RangeSpec,
mapping: dict[datetime, float],
) -> DashboardRangeSeries:
points = [
DashboardSeriesPoint(period=bucket, value=float(mapping.get(bucket, 0)))
@@ -94,7 +103,8 @@ def _series_from_mapping(
def _wip_series_from_mapping(
range_spec: RangeSpec, mapping: dict[datetime, dict[str, int]]
range_spec: RangeSpec,
mapping: dict[datetime, dict[str, int]],
) -> DashboardWipRangeSeries:
points: list[DashboardWipPoint] = []
for bucket in _build_buckets(range_spec):
@@ -105,7 +115,7 @@ def _wip_series_from_mapping(
inbox=values.get("inbox", 0),
in_progress=values.get("in_progress", 0),
review=values.get("review", 0),
)
),
)
return DashboardWipRangeSeries(
range=range_spec.key,
@@ -115,7 +125,9 @@ def _wip_series_from_mapping(
async def _query_throughput(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
statement = (
@@ -135,7 +147,9 @@ async def _query_throughput(
async def _query_cycle_time(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
in_progress = cast(Task.in_progress_at, DateTime)
@@ -158,9 +172,14 @@ async def _query_cycle_time(
async def _query_error_rate(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket")
bucket_col = func.date_trunc(
range_spec.bucket,
ActivityEvent.created_at,
).label("bucket")
error_case = case(
(
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
@@ -190,7 +209,9 @@ async def _query_error_rate(
async def _query_wip(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> DashboardWipRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
@@ -222,7 +243,10 @@ async def _query_wip(
return _wip_series_from_mapping(range_spec, mapping)
async def _median_cycle_time_7d(session: AsyncSession, board_ids: list[UUID]) -> float | None:
async def _median_cycle_time_7d(
session: AsyncSession,
board_ids: list[UUID],
) -> float | None:
now = utcnow()
start = now - timedelta(days=7)
in_progress = cast(Task.in_progress_at, DateTime)
@@ -248,7 +272,9 @@ async def _median_cycle_time_7d(session: AsyncSession, board_ids: list[UUID]) ->
async def _error_rate_kpi(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
session: AsyncSession,
range_spec: RangeSpec,
board_ids: list[UUID],
) -> float:
error_case = case(
(
@@ -302,12 +328,13 @@ async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> in
@router.get("/dashboard", response_model=DashboardMetrics)
async def dashboard_metrics(
range: Literal["24h", "7d"] = Query(default="24h"),
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
range_key: Literal["24h", "7d"] = RANGE_QUERY,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> DashboardMetrics:
primary = _resolve_range(range)
comparison = _comparison_range(range)
"""Return dashboard KPIs and time-series data for accessible boards."""
primary = _resolve_range(range_key)
comparison = _comparison_range(range_key)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
throughput_primary = await _query_throughput(session, primary, board_ids)