refactor: standardize runtime annotation types across multiple files

This commit is contained in:
Abhimanyu Saharan
2026-02-09 17:24:21 +05:30
parent 7706943209
commit f5d592f61a
47 changed files with 2203 additions and 1413 deletions

View File

@@ -63,7 +63,11 @@ from app.schemas.tasks import (
TaskUpdate, TaskUpdate,
) )
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.board_leads import ensure_board_lead_agent from app.services.board_leads import (
LeadAgentOptions,
LeadAgentRequest,
ensure_board_lead_agent,
)
from app.services.task_dependencies import ( from app.services.task_dependencies import (
blocked_by_dependency_ids, blocked_by_dependency_ids,
dependency_status_by_id, dependency_status_by_id,
@@ -113,6 +117,29 @@ class SoulUpdateRequest(SQLModel):
reason: str | None = None reason: str | None = None
class AgentTaskListFilters(SQLModel):
"""Query filters for board task listing in agent routes."""
status_filter: str | None = None
assigned_agent_id: UUID | None = None
unassigned: bool | None = None
def _task_list_filters(
status_filter: str | None = TASK_STATUS_QUERY,
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
) -> AgentTaskListFilters:
return AgentTaskListFilters(
status_filter=status_filter,
assigned_agent_id=assigned_agent_id,
unassigned=unassigned,
)
TASK_LIST_FILTERS_DEP = Depends(_task_list_filters)
def _actor(agent_ctx: AgentAuthContext) -> ActorContext: def _actor(agent_ctx: AgentAuthContext) -> ActorContext:
return ActorContext(actor_type="agent", agent=agent_ctx.agent) return ActorContext(actor_type="agent", agent=agent_ctx.agent)
@@ -217,19 +244,16 @@ async def list_agents(
statement = statement.where(Agent.board_id == agent_ctx.agent.board_id) statement = statement.where(Agent.board_id == agent_ctx.agent.board_id)
elif board_id: elif board_id:
statement = statement.where(Agent.board_id == board_id) statement = statement.where(Agent.board_id == board_id)
get_gateway_main_session_keys = ( main_session_keys = await agents_api.get_gateway_main_session_keys(session)
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()) statement = statement.order_by(col(Agent.created_at).desc())
def _transform(items: Sequence[Any]) -> Sequence[Any]: def _transform(items: Sequence[Any]) -> Sequence[Any]:
agents = cast(Sequence[Agent], items) agents = cast(Sequence[Agent], items)
return [ return [
to_agent_read(with_computed_status(agent), main_session_keys) agents_api.to_agent_read(
agents_api.with_computed_status(agent),
main_session_keys,
)
for agent in agents for agent in agents
] ]
@@ -237,10 +261,8 @@ async def list_agents(
@router.get("/boards/{board_id}/tasks", response_model=DefaultLimitOffsetPage[TaskRead]) @router.get("/boards/{board_id}/tasks", response_model=DefaultLimitOffsetPage[TaskRead])
async def list_tasks( # noqa: PLR0913 async def list_tasks(
status_filter: str | None = TASK_STATUS_QUERY, filters: AgentTaskListFilters = TASK_LIST_FILTERS_DEP,
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
board: Board = BOARD_DEP, board: Board = BOARD_DEP,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
@@ -248,9 +270,9 @@ async def list_tasks( # noqa: PLR0913
"""List tasks on a board with optional status and assignment filters.""" """List tasks on a board with optional status and assignment filters."""
_guard_board_access(agent_ctx, board) _guard_board_access(agent_ctx, board)
return await tasks_api.list_tasks( return await tasks_api.list_tasks(
status_filter=status_filter, status_filter=filters.status_filter,
assigned_agent_id=assigned_agent_id, assigned_agent_id=filters.assigned_agent_id,
unassigned=unassigned, unassigned=filters.unassigned,
board=board, board=board,
session=session, session=session,
actor=_actor(agent_ctx), actor=_actor(agent_ctx),
@@ -336,10 +358,7 @@ async def create_task(
session, session,
) )
if assigned_agent: if assigned_agent:
notify_agent_on_task_assign = ( await tasks_api.notify_agent_on_task_assign(
tasks_api._notify_agent_on_task_assign # noqa: SLF001
)
await notify_agent_on_task_assign(
session=session, session=session,
board=board, board=board,
task=task, task=task,
@@ -821,11 +840,13 @@ async def message_gateway_board_lead(
board = await _require_gateway_board(session, gateway=gateway, board_id=board_id) board = await _require_gateway_board(session, gateway=gateway, board_id=board_id)
lead, lead_created = await ensure_board_lead_agent( lead, lead_created = await ensure_board_lead_agent(
session, session,
request=LeadAgentRequest(
board=board, board=board,
gateway=gateway, gateway=gateway,
config=config, config=config,
user=None, user=None,
action="provision", options=LeadAgentOptions(action="provision"),
),
) )
if not lead.openclaw_session_id: if not lead.openclaw_session_id:
raise HTTPException( raise HTTPException(
@@ -932,11 +953,13 @@ async def broadcast_gateway_lead_message(
try: try:
lead, _lead_created = await ensure_board_lead_agent( lead, _lead_created = await ensure_board_lead_agent(
session, session,
request=LeadAgentRequest(
board=board, board=board,
gateway=gateway, gateway=gateway,
config=config, config=config,
user=None, user=None,
action="provision", options=LeadAgentOptions(action="provision"),
),
) )
lead_session_key = _require_lead_session_key(lead) lead_session_key = _require_lead_session_key(lead)
message = ( message = (

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import ValidationError from pydantic import ValidationError
@@ -19,7 +17,6 @@ from app.api.deps import (
require_admin_auth, require_admin_auth,
require_admin_or_agent, require_admin_or_agent,
) )
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.config import settings from app.core.config import settings
from app.core.time import utcnow from app.core.time import utcnow
from app.db.session import get_session from app.db.session import get_session
@@ -29,7 +26,6 @@ from app.integrations.openclaw_gateway import (
ensure_session, ensure_session,
send_message, send_message,
) )
from app.models.agents import Agent
from app.models.board_onboarding import BoardOnboardingSession from app.models.board_onboarding import BoardOnboardingSession
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.schemas.board_onboarding import ( from app.schemas.board_onboarding import (
@@ -43,7 +39,11 @@ from app.schemas.board_onboarding import (
BoardOnboardingUserProfile, BoardOnboardingUserProfile,
) )
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent from app.services.board_leads import (
LeadAgentOptions,
LeadAgentRequest,
ensure_board_lead_agent,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -72,93 +72,85 @@ async def _gateway_config(
return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token) return gateway, GatewayClientConfig(url=gateway.url, token=gateway.token)
def _build_session_key(agent_name: str) -> str: def _parse_draft_user_profile(
slug = re.sub(r"[^a-z0-9]+", "-", agent_name.lower()).strip("-") draft_goal: object,
return f"agent:{slug or uuid4().hex}:main" ) -> BoardOnboardingUserProfile | None:
if not isinstance(draft_goal, dict):
return None
def _lead_agent_name(_board: Board) -> str: raw_profile = draft_goal.get("user_profile")
return "Lead Agent" if raw_profile is None:
return None
def _lead_session_key(board: Board) -> str:
return f"agent:lead-{board.id}:main"
async def _ensure_lead_agent( # noqa: PLR0913
session: AsyncSession,
board: Board,
gateway: Gateway,
config: GatewayClientConfig,
auth: AuthContext,
*,
agent_name: str | None = None,
identity_profile: dict[str, str] | None = None,
) -> Agent:
existing = (
await Agent.objects.filter_by(board_id=board.id)
.filter(col(Agent.is_board_lead).is_(True))
.first(session)
)
if existing:
desired_name = agent_name or _lead_agent_name(board)
if existing.name != desired_name:
existing.name = desired_name
session.add(existing)
await session.commit()
await session.refresh(existing)
return existing
merged_identity_profile = {
"role": "Board Lead",
"communication_style": "direct, concise, practical",
"emoji": ":gear:",
}
if identity_profile:
merged_identity_profile.update(
{
key: value.strip()
for key, value in identity_profile.items()
if value.strip()
},
)
agent = Agent(
name=agent_name or _lead_agent_name(board),
status="provisioning",
board_id=board.id,
is_board_lead=True,
heartbeat_config=DEFAULT_HEARTBEAT_CONFIG.copy(),
identity_profile=merged_identity_profile,
)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
agent.provision_requested_at = utcnow()
agent.provision_action = "provision"
agent.openclaw_session_id = _lead_session_key(board)
session.add(agent)
await session.commit()
await session.refresh(agent)
try: try:
await provision_agent( return BoardOnboardingUserProfile.model_validate(raw_profile)
agent, board, gateway, raw_token, auth.user, action="provision", except ValidationError:
return None
def _parse_draft_lead_agent(
draft_goal: object,
) -> BoardOnboardingLeadAgentDraft | None:
if not isinstance(draft_goal, dict):
return None
raw_lead = draft_goal.get("lead_agent")
if raw_lead is None:
return None
try:
return BoardOnboardingLeadAgentDraft.model_validate(raw_lead)
except ValidationError:
return None
def _apply_user_profile(
auth: AuthContext,
profile: BoardOnboardingUserProfile | None,
) -> bool:
if auth.user is None or profile is None:
return False
changed = False
if profile.preferred_name is not None:
auth.user.preferred_name = profile.preferred_name
changed = True
if profile.pronouns is not None:
auth.user.pronouns = profile.pronouns
changed = True
if profile.timezone is not None:
auth.user.timezone = profile.timezone
changed = True
if profile.notes is not None:
auth.user.notes = profile.notes
changed = True
if profile.context is not None:
auth.user.context = profile.context
changed = True
return changed
def _lead_agent_options(
lead_agent: BoardOnboardingLeadAgentDraft | None,
) -> LeadAgentOptions:
if lead_agent is None:
return LeadAgentOptions(action="provision")
lead_identity_profile: dict[str, str] = {}
if lead_agent.identity_profile:
lead_identity_profile.update(lead_agent.identity_profile)
if lead_agent.autonomy_level:
lead_identity_profile["autonomy_level"] = lead_agent.autonomy_level
if lead_agent.verbosity:
lead_identity_profile["verbosity"] = lead_agent.verbosity
if lead_agent.output_format:
lead_identity_profile["output_format"] = lead_agent.output_format
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
return LeadAgentOptions(
agent_name=lead_agent.name,
identity_profile=lead_identity_profile or None,
action="provision",
) )
await ensure_session(agent.openclaw_session_id, config=config, label=agent.name)
await send_message(
(
f"Hello {agent.name}. Your workspace has been provisioned.\n\n"
"Start the agent, run BOOT.md, and if BOOTSTRAP.md exists run it once "
"then delete it. Begin heartbeats after startup."
),
session_key=agent.openclaw_session_id,
config=config,
deliver=True,
)
except OpenClawGatewayError:
# Best-effort provisioning. Board confirmation should still succeed.
pass
return agent
@router.get("", response_model=BoardOnboardingRead) @router.get("", response_model=BoardOnboardingRead)
@@ -400,7 +392,7 @@ async def agent_onboarding_update(
@router.post("/confirm", response_model=BoardRead) @router.post("/confirm", response_model=BoardRead)
async def confirm_onboarding( # noqa: C901, PLR0912, PLR0915 async def confirm_onboarding(
payload: BoardOnboardingConfirm, payload: BoardOnboardingConfirm,
board: Board = BOARD_USER_WRITE_DEP, board: Board = BOARD_USER_WRITE_DEP,
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
@@ -425,73 +417,26 @@ async def confirm_onboarding( # noqa: C901, PLR0912, PLR0915
onboarding.status = "confirmed" onboarding.status = "confirmed"
onboarding.updated_at = utcnow() onboarding.updated_at = utcnow()
user_profile: BoardOnboardingUserProfile | None = None user_profile = _parse_draft_user_profile(onboarding.draft_goal)
lead_agent: BoardOnboardingLeadAgentDraft | None = None if _apply_user_profile(auth, user_profile) and auth.user is not None:
if isinstance(onboarding.draft_goal, dict):
raw_profile = onboarding.draft_goal.get("user_profile")
if raw_profile is not None:
try:
user_profile = BoardOnboardingUserProfile.model_validate(raw_profile)
except ValidationError:
user_profile = None
raw_lead = onboarding.draft_goal.get("lead_agent")
if raw_lead is not None:
try:
lead_agent = BoardOnboardingLeadAgentDraft.model_validate(raw_lead)
except ValidationError:
lead_agent = None
if auth.user and user_profile:
changed = False
if user_profile.preferred_name is not None:
auth.user.preferred_name = user_profile.preferred_name
changed = True
if user_profile.pronouns is not None:
auth.user.pronouns = user_profile.pronouns
changed = True
if user_profile.timezone is not None:
auth.user.timezone = user_profile.timezone
changed = True
if user_profile.notes is not None:
auth.user.notes = user_profile.notes
changed = True
if user_profile.context is not None:
auth.user.context = user_profile.context
changed = True
if changed:
session.add(auth.user) session.add(auth.user)
lead_identity_profile: dict[str, str] = {} lead_agent = _parse_draft_lead_agent(onboarding.draft_goal)
lead_name: str | None = None lead_options = _lead_agent_options(lead_agent)
if lead_agent:
lead_name = lead_agent.name
if lead_agent.identity_profile:
lead_identity_profile.update(lead_agent.identity_profile)
if lead_agent.autonomy_level:
lead_identity_profile["autonomy_level"] = lead_agent.autonomy_level
if lead_agent.verbosity:
lead_identity_profile["verbosity"] = lead_agent.verbosity
if lead_agent.output_format:
lead_identity_profile["output_format"] = lead_agent.output_format
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
)
gateway, config = await _gateway_config(session, board) gateway, config = await _gateway_config(session, board)
session.add(board) session.add(board)
session.add(onboarding) session.add(onboarding)
await session.commit() await session.commit()
await session.refresh(board) await session.refresh(board)
await _ensure_lead_agent( await ensure_board_lead_agent(
session, session,
board, request=LeadAgentRequest(
gateway, board=board,
config, gateway=gateway,
auth, config=config,
agent_name=lead_name, user=auth.user,
identity_profile=lead_identity_profile or None, options=lead_options,
),
) )
return board return board

View File

@@ -34,6 +34,8 @@ from app.schemas.gateways import (
from app.schemas.pagination import DefaultLimitOffsetPage from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.agent_provisioning import ( from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG, DEFAULT_HEARTBEAT_CONFIG,
MainAgentProvisionRequest,
ProvisionOptions,
provision_main_agent, provision_main_agent,
) )
from app.services.template_sync import ( from app.services.template_sync import (
@@ -187,7 +189,15 @@ async def _ensure_main_agent(
await session.commit() await session.commit()
await session.refresh(agent) await session.refresh(agent)
try: try:
await provision_main_agent(agent, gateway, raw_token, auth.user, action=action) await provision_main_agent(
agent,
MainAgentProvisionRequest(
gateway=gateway,
auth_token=raw_token,
user=auth.user,
options=ProvisionOptions(action=action),
),
)
await ensure_session( await ensure_session(
gateway.main_session_key, gateway.main_session_key,
config=GatewayClientConfig(url=gateway.url, token=gateway.token), config=GatewayClientConfig(url=gateway.url, token=gateway.token),

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
@@ -10,6 +10,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class ActivityEvent(QueryModel, table=True): class ActivityEvent(QueryModel, table=True):
"""Discrete activity event tied to tasks and agents.""" """Discrete activity event tied to tasks and agents."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@@ -12,6 +12,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Agent(QueryModel, table=True): class Agent(QueryModel, table=True):
"""Agent configuration and lifecycle state persisted in the database.""" """Agent configuration and lifecycle state persisted in the database."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Approval(QueryModel, table=True): class Approval(QueryModel, table=True):
"""Approval request and decision metadata for gated operations.""" """Approval request and decision metadata for gated operations."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardGroupMemory(QueryModel, table=True): class BoardGroupMemory(QueryModel, table=True):
"""Persisted memory items associated with a board group.""" """Persisted memory items associated with a board group."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
@@ -10,6 +10,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.tenancy import TenantScoped from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardGroup(TenantScoped, table=True): class BoardGroup(TenantScoped, table=True):
"""Logical grouping container for boards within an organization.""" """Logical grouping container for boards within an organization."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardMemory(QueryModel, table=True): class BoardMemory(QueryModel, table=True):
"""Persisted memory item attached directly to a board.""" """Persisted memory item attached directly to a board."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class BoardOnboardingSession(QueryModel, table=True): class BoardOnboardingSession(QueryModel, table=True):
"""Persisted onboarding conversation and draft goal data for a board.""" """Persisted onboarding conversation and draft goal data for a board."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import JSON, Column from sqlalchemy import JSON, Column
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.tenancy import TenantScoped from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Board(TenantScoped, table=True): class Board(TenantScoped, table=True):
"""Primary board entity grouping tasks, agents, and goal metadata.""" """Primary board entity grouping tasks, agents, and goal metadata."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
@@ -10,6 +10,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Gateway(QueryModel, table=True): class Gateway(QueryModel, table=True):
"""Configured external gateway endpoint and authentication settings.""" """Configured external gateway endpoint and authentication settings."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class OrganizationBoardAccess(QueryModel, table=True): class OrganizationBoardAccess(QueryModel, table=True):
"""Member-specific board permissions within an organization.""" """Member-specific board permissions within an organization."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class OrganizationInviteBoardAccess(QueryModel, table=True): class OrganizationInviteBoardAccess(QueryModel, table=True):
"""Invite-specific board permissions applied after invite acceptance.""" """Invite-specific board permissions applied after invite acceptance."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class OrganizationInvite(QueryModel, table=True): class OrganizationInvite(QueryModel, table=True):
"""Invitation record granting prospective organization access.""" """Invitation record granting prospective organization access."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class OrganizationMember(QueryModel, table=True): class OrganizationMember(QueryModel, table=True):
"""Membership row linking a user to an organization and permissions.""" """Membership row linking a user to an organization and permissions."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint from sqlalchemy import UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Organization(QueryModel, table=True): class Organization(QueryModel, table=True):
"""Top-level organization tenant record.""" """Top-level organization tenant record."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import CheckConstraint, UniqueConstraint from sqlalchemy import CheckConstraint, UniqueConstraint
@@ -11,6 +11,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.tenancy import TenantScoped from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class TaskDependency(TenantScoped, table=True): class TaskDependency(TenantScoped, table=True):
"""Directed dependency edge between two tasks in the same board.""" """Directed dependency edge between two tasks in the same board."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
@@ -10,6 +10,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.base import QueryModel from app.models.base import QueryModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class TaskFingerprint(QueryModel, table=True): class TaskFingerprint(QueryModel, table=True):
"""Hashed task-content fingerprint associated with a board and task.""" """Hashed task-content fingerprint associated with a board and task."""

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlmodel import Field from sqlmodel import Field
@@ -10,6 +10,8 @@ from sqlmodel import Field
from app.core.time import utcnow from app.core.time import utcnow
from app.models.tenancy import TenantScoped from app.models.tenancy import TenantScoped
RUNTIME_ANNOTATION_TYPES = (datetime,)
class Task(TenantScoped, table=True): class Task(TenantScoped, table=True):
"""Board-scoped task entity with ownership, status, and timing fields.""" """Board-scoped task entity with ownership, status, and timing fields."""

View File

@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class ActivityEventRead(SQLModel): class ActivityEventRead(SQLModel):
"""Serialized activity event payload returned by activity endpoints.""" """Serialized activity event payload returned by activity endpoints."""

View File

@@ -2,15 +2,16 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from typing import Literal, Self from typing import Literal, Self
from uuid import UUID # noqa: TCH003 from uuid import UUID
from pydantic import model_validator from pydantic import model_validator
from sqlmodel import SQLModel from sqlmodel import SQLModel
ApprovalStatus = Literal["pending", "approved", "rejected"] ApprovalStatus = Literal["pending", "approved", "rejected"]
STATUS_REQUIRED_ERROR = "status is required" STATUS_REQUIRED_ERROR = "status is required"
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class ApprovalBase(SQLModel): class ApprovalBase(SQLModel):

View File

@@ -3,10 +3,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (UUID,)
class BoardGroupHeartbeatApply(SQLModel): class BoardGroupHeartbeatApply(SQLModel):
"""Request payload for heartbeat policy updates.""" """Request payload for heartbeat policy updates."""

View File

@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr # noqa: TCH001 from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
class BoardGroupMemoryCreate(SQLModel): class BoardGroupMemoryCreate(SQLModel):

View File

@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class BoardGroupBase(SQLModel): class BoardGroupBase(SQLModel):
"""Shared board-group fields for create/read operations.""" """Shared board-group fields for create/read operations."""

View File

@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr # noqa: TCH001 from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
class BoardMemoryCreate(SQLModel): class BoardMemoryCreate(SQLModel):

View File

@@ -2,9 +2,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from typing import Self from typing import Self
from uuid import UUID # noqa: TCH003 from uuid import UUID
from pydantic import model_validator from pydantic import model_validator
from sqlmodel import SQLModel from sqlmodel import SQLModel
@@ -13,6 +13,7 @@ _ERR_GOAL_FIELDS_REQUIRED = (
"Confirmed goal boards require objective and success_metrics" "Confirmed goal boards require objective and success_metrics"
) )
_ERR_GATEWAY_REQUIRED = "gateway_id is required" _ERR_GATEWAY_REQUIRED = "gateway_id is required"
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class BoardBase(SQLModel): class BoardBase(SQLModel):

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
from sqlmodel import SQLModel from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr # noqa: TCH001 from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (NonEmptyStr,)
class GatewaySessionMessageRequest(SQLModel): class GatewaySessionMessageRequest(SQLModel):

View File

@@ -3,11 +3,13 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from typing import Literal
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr # noqa: TCH001 from app.schemas.common import NonEmptyStr
RUNTIME_ANNOTATION_TYPES = (UUID, NonEmptyStr)
def _lead_reply_tags() -> list[str]: def _lead_reply_tags() -> list[str]:

View File

@@ -2,12 +2,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from pydantic import field_validator from pydantic import field_validator
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class GatewayBase(SQLModel): class GatewayBase(SQLModel):
"""Shared gateway fields used across create/read payloads.""" """Shared gateway fields used across create/read payloads."""

View File

@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from typing import Literal from typing import Literal
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime,)
class DashboardSeriesPoint(SQLModel): class DashboardSeriesPoint(SQLModel):
"""Single numeric time-series point.""" """Single numeric time-series point."""

View File

@@ -2,11 +2,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
RUNTIME_ANNOTATION_TYPES = (datetime, UUID)
class OrganizationRead(SQLModel): class OrganizationRead(SQLModel):
"""Organization payload returned by read endpoints.""" """Organization payload returned by read endpoints."""

View File

@@ -2,17 +2,20 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from typing import Literal, Self from typing import Literal, Self
from uuid import UUID # noqa: TCH003 from uuid import UUID
from pydantic import field_validator, model_validator from pydantic import field_validator, model_validator
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr # noqa: TCH001 from app.schemas.common import NonEmptyStr
TaskStatus = Literal["inbox", "in_progress", "review", "done"] TaskStatus = Literal["inbox", "in_progress", "review", "done"]
STATUS_REQUIRED_ERROR = "status is required" STATUS_REQUIRED_ERROR = "status is required"
# Keep these symbols as runtime globals so Pydantic can resolve
# deferred annotations reliably.
RUNTIME_ANNOTATION_TYPES = (datetime, UUID, NonEmptyStr)
class TaskBase(SQLModel): class TaskBase(SQLModel):

View File

@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import SQLModel from sqlmodel import SQLModel
RUNTIME_ANNOTATION_TYPES = (UUID,)
class UserBase(SQLModel): class UserBase(SQLModel):
"""Common user profile fields shared across user payload schemas.""" """Common user profile fields shared across user payload schemas."""

View File

@@ -2,18 +2,28 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime # noqa: TCH003 from datetime import datetime
from uuid import UUID # noqa: TCH003 from uuid import UUID
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.schemas.agents import AgentRead # noqa: TCH001 from app.schemas.agents import AgentRead
from app.schemas.approvals import ApprovalRead # noqa: TCH001 from app.schemas.approvals import ApprovalRead
from app.schemas.board_groups import BoardGroupRead # noqa: TCH001 from app.schemas.board_groups import BoardGroupRead
from app.schemas.board_memory import BoardMemoryRead # noqa: TCH001 from app.schemas.board_memory import BoardMemoryRead
from app.schemas.boards import BoardRead # noqa: TCH001 from app.schemas.boards import BoardRead
from app.schemas.tasks import TaskRead from app.schemas.tasks import TaskRead
RUNTIME_ANNOTATION_TYPES = (
datetime,
UUID,
AgentRead,
ApprovalRead,
BoardGroupRead,
BoardMemoryRead,
BoardRead,
)
class TaskCardRead(TaskRead): class TaskCardRead(TaskRead):
"""Task read model enriched with assignee and approval counters.""" """Task read model enriched with assignee and approval counters."""

View File

@@ -6,6 +6,7 @@ import hashlib
import json import json
import re import re
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4 from uuid import uuid4
@@ -88,6 +89,36 @@ MAIN_TEMPLATE_MAP = {
} }
@dataclass(frozen=True, slots=True)
class ProvisionOptions:
"""Toggles controlling provisioning write/reset behavior."""
action: str = "provision"
force_bootstrap: bool = False
reset_session: bool = False
@dataclass(frozen=True, slots=True)
class AgentProvisionRequest:
"""Inputs required to provision a board-scoped agent."""
board: Board
gateway: Gateway
auth_token: str
user: User | None
options: ProvisionOptions = field(default_factory=ProvisionOptions)
@dataclass(frozen=True, slots=True)
class MainAgentProvisionRequest:
"""Inputs required to provision a gateway main agent."""
gateway: Gateway
auth_token: str
user: User | None
options: ProvisionOptions = field(default_factory=ProvisionOptions)
def _repo_root() -> Path: def _repo_root() -> Path:
return Path(__file__).resolve().parents[3] return Path(__file__).resolve().parents[3]
@@ -114,31 +145,48 @@ def _agent_id_from_session_key(session_key: str | None) -> str | None:
return agent_id or None return agent_id or None
def _extract_agent_id(payload: object) -> str | None: # noqa: C901 def _clean_str(value: object) -> str | None:
def _from_list(items: object) -> str | None: if isinstance(value, str) and value.strip():
return value.strip()
return None
def _extract_agent_id_from_item(item: object) -> str | None:
if isinstance(item, str):
return _clean_str(item)
if not isinstance(item, dict):
return None
for key in ("id", "agentId", "agent_id"):
agent_id = _clean_str(item.get(key))
if agent_id:
return agent_id
return None
def _extract_agent_id_from_list(items: object) -> str | None:
if not isinstance(items, list): if not isinstance(items, list):
return None return None
for item in items: for item in items:
if isinstance(item, str) and item.strip(): agent_id = _extract_agent_id_from_item(item)
return item.strip() if agent_id:
if not isinstance(item, dict): return agent_id
continue
for key in ("id", "agentId", "agent_id"):
raw = item.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None return None
def _extract_agent_id(payload: object) -> str | None:
default_keys = ("defaultId", "default_id", "defaultAgentId", "default_agent_id")
collection_keys = ("agents", "items", "list", "data")
if isinstance(payload, list): if isinstance(payload, list):
return _from_list(payload) return _extract_agent_id_from_list(payload)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"): for key in default_keys:
raw = payload.get(key) agent_id = _clean_str(payload.get(key))
if isinstance(raw, str) and raw.strip(): if agent_id:
return raw.strip() return agent_id
for key in ("agents", "items", "list", "data"): for key in collection_keys:
agent_id = _from_list(payload.get(key)) agent_id = _extract_agent_id_from_list(payload.get(key))
if agent_id: if agent_id:
return agent_id return agent_id
return None return None
@@ -523,42 +571,44 @@ async def _patch_gateway_agent_list(
await openclaw_call("config.patch", params, config=config) await openclaw_call("config.patch", params, config=config)
async def patch_gateway_agent_heartbeats( # noqa: C901 async def _gateway_config_agent_list(
gateway: Gateway, config: GatewayClientConfig,
*, ) -> tuple[str | None, list[object]]:
entries: list[tuple[str, str, dict[str, Any]]],
) -> None:
"""Patch multiple agent heartbeat configs in a single gateway config.patch call.
Each entry is (agent_id, workspace_path, heartbeat_dict).
"""
if not gateway.url:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
cfg = await openclaw_call("config.get", config=config) cfg = await openclaw_call("config.get", config=config)
if not isinstance(cfg, dict): if not isinstance(cfg, dict):
msg = "config.get returned invalid payload" msg = "config.get returned invalid payload"
raise OpenClawGatewayError(msg) raise OpenClawGatewayError(msg)
base_hash = cfg.get("hash")
data = cfg.get("config") or cfg.get("parsed") or {} data = cfg.get("config") or cfg.get("parsed") or {}
if not isinstance(data, dict): if not isinstance(data, dict):
msg = "config.get returned invalid config" msg = "config.get returned invalid config"
raise OpenClawGatewayError(msg) raise OpenClawGatewayError(msg)
agents_section = data.get("agents") or {} agents_section = data.get("agents") or {}
lst = agents_section.get("list") or [] agents_list = agents_section.get("list") or []
if not isinstance(lst, list): if not isinstance(agents_list, list):
msg = "config agents.list is not a list" msg = "config agents.list is not a list"
raise OpenClawGatewayError(msg) raise OpenClawGatewayError(msg)
return cfg.get("hash"), agents_list
entry_by_id: dict[str, tuple[str, dict[str, Any]]] = {
def _heartbeat_entry_map(
entries: list[tuple[str, str, dict[str, Any]]],
) -> dict[str, tuple[str, dict[str, Any]]]:
return {
agent_id: (workspace_path, heartbeat) agent_id: (workspace_path, heartbeat)
for agent_id, workspace_path, heartbeat in entries for agent_id, workspace_path, heartbeat in entries
} }
def _updated_agent_list(
raw_list: list[object],
entry_by_id: dict[str, tuple[str, dict[str, Any]]],
) -> list[object]:
updated_ids: set[str] = set() updated_ids: set[str] = set()
new_list: list[dict[str, Any]] = [] new_list: list[object] = []
for raw_entry in lst:
for raw_entry in raw_list:
if not isinstance(raw_entry, dict): if not isinstance(raw_entry, dict):
new_list.append(raw_entry) new_list.append(raw_entry)
continue continue
@@ -566,6 +616,7 @@ async def patch_gateway_agent_heartbeats( # noqa: C901
if not isinstance(agent_id, str) or agent_id not in entry_by_id: if not isinstance(agent_id, str) or agent_id not in entry_by_id:
new_list.append(raw_entry) new_list.append(raw_entry)
continue continue
workspace_path, heartbeat = entry_by_id[agent_id] workspace_path, heartbeat = entry_by_id[agent_id]
new_entry = dict(raw_entry) new_entry = dict(raw_entry)
new_entry["workspace"] = workspace_path new_entry["workspace"] = workspace_path
@@ -580,6 +631,26 @@ async def patch_gateway_agent_heartbeats( # noqa: C901
{"id": agent_id, "workspace": workspace_path, "heartbeat": heartbeat}, {"id": agent_id, "workspace": workspace_path, "heartbeat": heartbeat},
) )
return new_list
async def patch_gateway_agent_heartbeats(
gateway: Gateway,
*,
entries: list[tuple[str, str, dict[str, Any]]],
) -> None:
"""Patch multiple agent heartbeat configs in a single gateway config.patch call.
Each entry is (agent_id, workspace_path, heartbeat_dict).
"""
if not gateway.url:
msg = "Gateway url is required"
raise OpenClawGatewayError(msg)
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
base_hash, raw_list = await _gateway_config_agent_list(config)
entry_by_id = _heartbeat_entry_map(entries)
new_list = _updated_agent_list(raw_list, entry_by_id)
patch = {"agents": {"list": new_list}} patch = {"agents": {"list": new_list}}
params = {"raw": json.dumps(patch)} params = {"raw": json.dumps(patch)}
if base_hash: if base_hash:
@@ -656,18 +727,52 @@ async def _get_gateway_agent_entry(
return None return None
async def provision_agent( # noqa: C901, PLR0912, PLR0913 def _should_include_bootstrap(
agent: Agent,
board: Board,
gateway: Gateway,
auth_token: str,
user: User | None,
*, *,
action: str = "provision", action: str,
force_bootstrap: bool = False, force_bootstrap: bool,
reset_session: bool = False, existing_files: dict[str, dict[str, Any]],
) -> bool:
if action != "update" or force_bootstrap:
return True
if not existing_files:
return False
entry = existing_files.get("BOOTSTRAP.md")
return not (entry and entry.get("missing") is True)
async def _set_agent_files(
*,
agent_id: str,
rendered: dict[str, str],
existing_files: dict[str, dict[str, Any]],
client_config: GatewayClientConfig,
) -> None:
for name, content in rendered.items():
if content == "":
continue
if name in PRESERVE_AGENT_EDITABLE_FILES:
entry = existing_files.get(name)
if entry and entry.get("missing") is not True:
continue
try:
await openclaw_call(
"agents.files.set",
{"agentId": agent_id, "name": name, "content": content},
config=client_config,
)
except OpenClawGatewayError as exc:
if "unsupported file" in str(exc).lower():
continue
raise
async def provision_agent(
agent: Agent,
request: AgentProvisionRequest,
) -> None: ) -> None:
"""Provision or update a regular board agent workspace.""" """Provision or update a regular board agent workspace."""
gateway = request.gateway
if not gateway.url: if not gateway.url:
return return
if not gateway.workspace_root: if not gateway.workspace_root:
@@ -682,18 +787,21 @@ async def provision_agent( # noqa: C901, PLR0912, PLR0913
heartbeat = _heartbeat_config(agent) heartbeat = _heartbeat_config(agent)
await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config) await _patch_gateway_agent_list(agent_id, workspace_path, heartbeat, client_config)
context = _build_context(agent, board, gateway, auth_token, user) context = _build_context(
agent,
request.board,
gateway,
request.auth_token,
request.user,
)
supported = set(await _supported_gateway_files(client_config)) supported = set(await _supported_gateway_files(client_config))
supported.update({"USER.md", "SELF.md", "AUTONOMY.md"}) supported.update({"USER.md", "SELF.md", "AUTONOMY.md"})
existing_files = await _gateway_agent_files_index(agent_id, client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = True include_bootstrap = _should_include_bootstrap(
if action == "update" and not force_bootstrap: action=request.options.action,
if not existing_files: force_bootstrap=request.options.force_bootstrap,
include_bootstrap = False existing_files=existing_files,
else: )
entry = existing_files.get("BOOTSTRAP.md")
if entry and entry.get("missing") is True:
include_bootstrap = False
rendered = _render_agent_files( rendered = _render_agent_files(
context, context,
@@ -710,41 +818,22 @@ async def provision_agent( # noqa: C901, PLR0912, PLR0913
with suppress(OSError): with suppress(OSError):
# Local workspace may not be writable/available; fall back to gateway API. # Local workspace may not be writable/available; fall back to gateway API.
_ensure_workspace_file(workspace_path, name, content, overwrite=False) _ensure_workspace_file(workspace_path, name, content, overwrite=False)
for name, content in rendered.items(): await _set_agent_files(
if content == "": agent_id=agent_id,
continue rendered=rendered,
if name in PRESERVE_AGENT_EDITABLE_FILES: existing_files=existing_files,
# Never overwrite; only provision if missing. client_config=client_config,
entry = existing_files.get(name)
if entry and entry.get("missing") is not True:
continue
try:
await openclaw_call(
"agents.files.set",
{"agentId": agent_id, "name": name, "content": content},
config=client_config,
) )
except OpenClawGatewayError as exc: if request.options.reset_session:
# Gateways may restrict file names. Skip unsupported files rather than
# failing provisioning for the entire agent.
if "unsupported file" in str(exc).lower():
continue
raise
if reset_session:
await _reset_session(session_key, client_config) await _reset_session(session_key, client_config)
async def provision_main_agent( # noqa: C901, PLR0912, PLR0913 async def provision_main_agent(
agent: Agent, agent: Agent,
gateway: Gateway, request: MainAgentProvisionRequest,
auth_token: str,
user: User | None,
*,
action: str = "provision",
force_bootstrap: bool = False,
reset_session: bool = False,
) -> None: ) -> None:
"""Provision or update the gateway main agent workspace.""" """Provision or update the gateway main agent workspace."""
gateway = request.gateway
if not gateway.url: if not gateway.url:
return return
if not gateway.main_session_key: if not gateway.main_session_key:
@@ -763,18 +852,15 @@ async def provision_main_agent( # noqa: C901, PLR0912, PLR0913
msg = "Unable to resolve gateway main agent id" msg = "Unable to resolve gateway main agent id"
raise OpenClawGatewayError(msg) raise OpenClawGatewayError(msg)
context = _build_main_context(agent, gateway, auth_token, user) context = _build_main_context(agent, gateway, request.auth_token, request.user)
supported = set(await _supported_gateway_files(client_config)) supported = set(await _supported_gateway_files(client_config))
supported.update({"USER.md", "SELF.md", "AUTONOMY.md"}) supported.update({"USER.md", "SELF.md", "AUTONOMY.md"})
existing_files = await _gateway_agent_files_index(agent_id, client_config) existing_files = await _gateway_agent_files_index(agent_id, client_config)
include_bootstrap = action != "update" or force_bootstrap include_bootstrap = _should_include_bootstrap(
if action == "update" and not force_bootstrap: action=request.options.action,
if not existing_files: force_bootstrap=request.options.force_bootstrap,
include_bootstrap = False existing_files=existing_files,
else: )
entry = existing_files.get("BOOTSTRAP.md")
if entry and entry.get("missing") is True:
include_bootstrap = False
rendered = _render_agent_files( rendered = _render_agent_files(
context, context,
@@ -783,24 +869,13 @@ async def provision_main_agent( # noqa: C901, PLR0912, PLR0913
include_bootstrap=include_bootstrap, include_bootstrap=include_bootstrap,
template_overrides=MAIN_TEMPLATE_MAP, template_overrides=MAIN_TEMPLATE_MAP,
) )
for name, content in rendered.items(): await _set_agent_files(
if content == "": agent_id=agent_id,
continue rendered=rendered,
if name in PRESERVE_AGENT_EDITABLE_FILES: existing_files=existing_files,
entry = existing_files.get(name) client_config=client_config,
if entry and entry.get("missing") is not True:
continue
try:
await openclaw_call(
"agents.files.set",
{"agentId": agent_id, "name": name, "content": content},
config=client_config,
) )
except OpenClawGatewayError as exc: if request.options.reset_session:
if "unsupported file" in str(exc).lower():
continue
raise
if reset_session:
await _reset_session(gateway.main_session_key, client_config) await _reset_session(gateway.main_session_key, client_config)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from sqlmodel import col, select from sqlmodel import col, select
@@ -15,7 +16,12 @@ from app.integrations.openclaw_gateway import (
send_message, send_message,
) )
from app.models.agents import Agent from app.models.agents import Agent
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent from app.services.agent_provisioning import (
DEFAULT_HEARTBEAT_CONFIG,
AgentProvisionRequest,
ProvisionOptions,
provision_agent,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -35,18 +41,34 @@ def lead_agent_name(_: Board) -> str:
return "Lead Agent" return "Lead Agent"
async def ensure_board_lead_agent( # noqa: PLR0913 @dataclass(frozen=True, slots=True)
class LeadAgentOptions:
"""Optional overrides for board-lead provisioning behavior."""
agent_name: str | None = None
identity_profile: dict[str, str] | None = None
action: str = "provision"
@dataclass(frozen=True, slots=True)
class LeadAgentRequest:
"""Inputs required to ensure or provision a board lead agent."""
board: Board
gateway: Gateway
config: GatewayClientConfig
user: User | None
options: LeadAgentOptions = field(default_factory=LeadAgentOptions)
async def ensure_board_lead_agent(
session: AsyncSession, session: AsyncSession,
*, *,
board: Board, request: LeadAgentRequest,
gateway: Gateway,
config: GatewayClientConfig,
user: User | None,
agent_name: str | None = None,
identity_profile: dict[str, str] | None = None,
action: str = "provision",
) -> tuple[Agent, bool]: ) -> tuple[Agent, bool]:
"""Ensure a board has a lead agent; return `(agent, created)`.""" """Ensure a board has a lead agent; return `(agent, created)`."""
board = request.board
config_options = request.options
existing = ( existing = (
await session.exec( await session.exec(
select(Agent) select(Agent)
@@ -55,7 +77,7 @@ async def ensure_board_lead_agent( # noqa: PLR0913
) )
).first() ).first()
if existing: if existing:
desired_name = agent_name or lead_agent_name(board) desired_name = config_options.agent_name or lead_agent_name(board)
changed = False changed = False
if existing.name != desired_name: if existing.name != desired_name:
existing.name = desired_name existing.name = desired_name
@@ -76,17 +98,17 @@ async def ensure_board_lead_agent( # noqa: PLR0913
"communication_style": "direct, concise, practical", "communication_style": "direct, concise, practical",
"emoji": ":gear:", "emoji": ":gear:",
} }
if identity_profile: if config_options.identity_profile:
merged_identity_profile.update( merged_identity_profile.update(
{ {
key: value.strip() key: value.strip()
for key, value in identity_profile.items() for key, value in config_options.identity_profile.items()
if value.strip() if value.strip()
}, },
) )
agent = Agent( agent = Agent(
name=agent_name or lead_agent_name(board), name=config_options.agent_name or lead_agent_name(board),
status="provisioning", status="provisioning",
board_id=board.id, board_id=board.id,
is_board_lead=True, is_board_lead=True,
@@ -94,7 +116,7 @@ async def ensure_board_lead_agent( # noqa: PLR0913
identity_profile=merged_identity_profile, identity_profile=merged_identity_profile,
openclaw_session_id=lead_session_key(board), openclaw_session_id=lead_session_key(board),
provision_requested_at=utcnow(), provision_requested_at=utcnow(),
provision_action=action, provision_action=config_options.action,
) )
raw_token = generate_agent_token() raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token) agent.agent_token_hash = hash_agent_token(raw_token)
@@ -103,11 +125,20 @@ async def ensure_board_lead_agent( # noqa: PLR0913
await session.refresh(agent) await session.refresh(agent)
try: try:
await provision_agent(agent, board, gateway, raw_token, user, action=action) await provision_agent(
agent,
AgentProvisionRequest(
board=board,
gateway=request.gateway,
auth_token=raw_token,
user=request.user,
options=ProvisionOptions(action=config_options.action),
),
)
if agent.openclaw_session_id: if agent.openclaw_session_id:
await ensure_session( await ensure_session(
agent.openclaw_session_id, agent.openclaw_session_id,
config=config, config=request.config,
label=agent.name, label=agent.name,
) )
await send_message( await send_message(
@@ -118,7 +149,7 @@ async def ensure_board_lead_agent( # noqa: PLR0913
"then delete it. Begin heartbeats after startup." "then delete it. Begin heartbeats after startup."
), ),
session_key=agent.openclaw_session_id, session_key=agent.openclaw_session_id,
config=config, config=request.config,
deliver=True, deliver=True,
) )
except OpenClawGatewayError: except OpenClawGatewayError:

View File

@@ -2,9 +2,10 @@
from __future__ import annotations from __future__ import annotations
import re
import time import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from html import unescape
from typing import Final from typing import Final
import httpx import httpx
@@ -14,6 +15,10 @@ SOULS_DIRECTORY_SITEMAP_URL: Final[str] = f"{SOULS_DIRECTORY_BASE_URL}/sitemap.x
_SITEMAP_TTL_SECONDS: Final[int] = 60 * 60 _SITEMAP_TTL_SECONDS: Final[int] = 60 * 60
_SOUL_URL_MIN_PARTS: Final[int] = 6 _SOUL_URL_MIN_PARTS: Final[int] = 6
_LOC_PATTERN: Final[re.Pattern[str]] = re.compile(
r"<(?:[A-Za-z0-9_]+:)?loc>(.*?)</(?:[A-Za-z0-9_]+:)?loc>",
flags=re.IGNORECASE | re.DOTALL,
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -36,17 +41,10 @@ class SoulRef:
def _parse_sitemap_soul_refs(sitemap_xml: str) -> list[SoulRef]: def _parse_sitemap_soul_refs(sitemap_xml: str) -> list[SoulRef]:
"""Parse sitemap XML and extract valid souls.directory handle/slug refs.""" """Parse sitemap XML and extract valid souls.directory handle/slug refs."""
try: # Extract <loc> values without XML entity expansion.
# Souls sitemap is fetched from a known trusted host in this service flow.
root = ET.fromstring(sitemap_xml) # noqa: S314
except ET.ParseError:
return []
# Handle both namespaced and non-namespaced sitemap XML.
urls = [ urls = [
loc.text.strip() unescape(match.group(1)).strip()
for loc in root.iter() for match in _LOC_PATTERN.finditer(sitemap_xml)
if loc.tag.endswith("loc") and loc.text
] ]
refs: list[SoulRef] = [] refs: list[SoulRef] = []

View File

@@ -28,7 +28,13 @@ from app.models.boards import Board
from app.models.gateways import Gateway from app.models.gateways import Gateway
from app.models.users import User from app.models.users import User
from app.schemas.gateways import GatewayTemplatesSyncError, GatewayTemplatesSyncResult from app.schemas.gateways import GatewayTemplatesSyncError, GatewayTemplatesSyncResult
from app.services.agent_provisioning import provision_agent, provision_main_agent from app.services.agent_provisioning import (
AgentProvisionRequest,
MainAgentProvisionRequest,
ProvisionOptions,
provision_agent,
provision_main_agent,
)
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$") _TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
SESSION_KEY_PARTS_MIN = 2 SESSION_KEY_PARTS_MIN = 2
@@ -480,13 +486,17 @@ async def _sync_one_agent(
async def _do_provision() -> None: async def _do_provision() -> None:
await provision_agent( await provision_agent(
agent, agent,
board, AgentProvisionRequest(
ctx.gateway, board=board,
auth_token, gateway=ctx.gateway,
ctx.options.user, auth_token=auth_token,
user=ctx.options.user,
options=ProvisionOptions(
action="update", action="update",
force_bootstrap=ctx.options.force_bootstrap, force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions, reset_session=ctx.options.reset_sessions,
),
),
) )
await _with_gateway_retry(_do_provision, backoff=ctx.backoff) await _with_gateway_retry(_do_provision, backoff=ctx.backoff)
@@ -564,12 +574,16 @@ async def _sync_main_agent(
async def _do_provision_main() -> None: async def _do_provision_main() -> None:
await provision_main_agent( await provision_main_agent(
main_agent, main_agent,
ctx.gateway, MainAgentProvisionRequest(
token, gateway=ctx.gateway,
ctx.options.user, auth_token=token,
user=ctx.options.user,
options=ProvisionOptions(
action="update", action="update",
force_bootstrap=ctx.options.force_bootstrap, force_bootstrap=ctx.options.force_bootstrap,
reset_session=ctx.options.reset_sessions, reset_session=ctx.options.reset_sessions,
),
),
) )
await _with_gateway_retry(_do_provision_main, backoff=ctx.backoff) await _with_gateway_retry(_do_provision_main, backoff=ctx.backoff)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import importlib
import sys import sys
from logging.config import fileConfig from logging.config import fileConfig
from pathlib import Path from pathlib import Path
@@ -14,8 +15,8 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path: if str(PROJECT_ROOT) not in sys.path:
sys.path.append(str(PROJECT_ROOT)) sys.path.append(str(PROJECT_ROOT))
from app import models # noqa: E402,F401 importlib.import_module("app.models")
from app.core.config import settings # noqa: E402 settings = importlib.import_module("app.core.config").settings
config = context.config config = context.config
configure_logger = config.attributes.get("configure_logger", True) configure_logger = config.attributes.get("configure_logger", True)

View File

@@ -8,7 +8,6 @@ Create Date: 2026-02-09 00:41:55.760624
from __future__ import annotations from __future__ import annotations
# ruff: noqa: INP001
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op from alembic import op
@@ -20,8 +19,17 @@ branch_labels = None
depends_on = None depends_on = None
def upgrade() -> None: # noqa: PLR0915
def upgrade() -> None:
"""Create initial schema objects.""" """Create initial schema objects."""
_upgrade_part_1()
_upgrade_part_2()
_upgrade_part_3()
_upgrade_part_4()
def _upgrade_part_1() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table( op.create_table(
"organizations", "organizations",
@@ -183,6 +191,9 @@ def upgrade() -> None: # noqa: PLR0915
op.f("ix_boards_organization_id"), "boards", ["organization_id"], unique=False, op.f("ix_boards_organization_id"), "boards", ["organization_id"], unique=False,
) )
op.create_index(op.f("ix_boards_slug"), "boards", ["slug"], unique=False) op.create_index(op.f("ix_boards_slug"), "boards", ["slug"], unique=False)
def _upgrade_part_2() -> None:
op.create_table( op.create_table(
"organization_invites", "organization_invites",
sa.Column("id", sa.Uuid(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False),
@@ -366,6 +377,9 @@ def upgrade() -> None: # noqa: PLR0915
unique=False, unique=False,
) )
op.create_index(op.f("ix_agents_status"), "agents", ["status"], unique=False) op.create_index(op.f("ix_agents_status"), "agents", ["status"], unique=False)
def _upgrade_part_3() -> None:
op.create_table( op.create_table(
"board_memory", "board_memory",
sa.Column("id", sa.Uuid(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False),
@@ -532,6 +546,9 @@ def upgrade() -> None: # noqa: PLR0915
) )
op.create_index(op.f("ix_tasks_priority"), "tasks", ["priority"], unique=False) op.create_index(op.f("ix_tasks_priority"), "tasks", ["priority"], unique=False)
op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False) op.create_index(op.f("ix_tasks_status"), "tasks", ["status"], unique=False)
def _upgrade_part_4() -> None:
op.create_table( op.create_table(
"activity_events", "activity_events",
sa.Column("id", sa.Uuid(), nullable=False), sa.Column("id", sa.Uuid(), nullable=False),
@@ -686,8 +703,14 @@ def upgrade() -> None: # noqa: PLR0915
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: # noqa: PLR0915 def downgrade() -> None:
"""Drop initial schema objects.""" """Drop initial schema objects."""
_downgrade_part_1()
_downgrade_part_2()
_downgrade_part_3()
def _downgrade_part_1() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index( op.drop_index(
op.f("ix_task_fingerprints_fingerprint_hash"), table_name="task_fingerprints", op.f("ix_task_fingerprints_fingerprint_hash"), table_name="task_fingerprints",
@@ -745,6 +768,9 @@ def downgrade() -> None: # noqa: PLR0915
op.drop_index(op.f("ix_board_memory_is_chat"), table_name="board_memory") op.drop_index(op.f("ix_board_memory_is_chat"), table_name="board_memory")
op.drop_index(op.f("ix_board_memory_board_id"), table_name="board_memory") op.drop_index(op.f("ix_board_memory_board_id"), table_name="board_memory")
op.drop_table("board_memory") op.drop_table("board_memory")
def _downgrade_part_2() -> None:
op.drop_index(op.f("ix_agents_status"), table_name="agents") op.drop_index(op.f("ix_agents_status"), table_name="agents")
op.drop_index(op.f("ix_agents_provision_confirm_token_hash"), table_name="agents") op.drop_index(op.f("ix_agents_provision_confirm_token_hash"), table_name="agents")
op.drop_index(op.f("ix_agents_provision_action"), table_name="agents") op.drop_index(op.f("ix_agents_provision_action"), table_name="agents")
@@ -795,6 +821,9 @@ def downgrade() -> None: # noqa: PLR0915
op.drop_index(op.f("ix_boards_board_type"), table_name="boards") op.drop_index(op.f("ix_boards_board_type"), table_name="boards")
op.drop_index(op.f("ix_boards_board_group_id"), table_name="boards") op.drop_index(op.f("ix_boards_board_group_id"), table_name="boards")
op.drop_table("boards") op.drop_table("boards")
def _downgrade_part_3() -> None:
op.drop_index( op.drop_index(
op.f("ix_board_group_memory_is_chat"), table_name="board_group_memory", op.f("ix_board_group_memory_is_chat"), table_name="board_group_memory",
) )

View File

@@ -0,0 +1 @@
"""Alembic migration version modules."""

View File

@@ -9,11 +9,11 @@ from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1] BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))
from app.main import app # noqa: E402
def main() -> None: def main() -> None:
"""Generate `openapi.json` from the FastAPI app definition.""" """Generate `openapi.json` from the FastAPI app definition."""
from app.main import app
# Importing the FastAPI app does not run lifespan hooks, # Importing the FastAPI app does not run lifespan hooks,
# so this does not require a DB. # so this does not require a DB.
out_path = BACKEND_ROOT / "openapi.json" out_path = BACKEND_ROOT / "openapi.json"

View File

@@ -10,15 +10,15 @@ from uuid import uuid4
BACKEND_ROOT = Path(__file__).resolve().parents[1] BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))
from app.db.session import async_session_maker, init_db # noqa: E402
from app.models.agents import Agent # noqa: E402
from app.models.boards import Board # noqa: E402
from app.models.gateways import Gateway # noqa: E402
from app.models.users import User # noqa: E402
async def run() -> None: async def run() -> None:
"""Populate the local database with a demo gateway, board, user, and agent.""" """Populate the local database with a demo gateway, board, user, and agent."""
from app.db.session import async_session_maker, init_db
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
await init_db() await init_db()
async with async_session_maker() as session: async with async_session_maker() as session:
demo_workspace_root = BACKEND_ROOT / ".tmp" / "openclaw-demo" demo_workspace_root = BACKEND_ROOT / ".tmp" / "openclaw-demo"

View File

@@ -1,4 +1,3 @@
# ruff: noqa: INP001
"""CLI script to sync template files into gateway agent workspaces.""" """CLI script to sync template files into gateway agent workspaces."""
from __future__ import annotations from __future__ import annotations
@@ -12,10 +11,6 @@ from uuid import UUID
BACKEND_ROOT = Path(__file__).resolve().parents[1] BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT)) sys.path.insert(0, str(BACKEND_ROOT))
from app.db.session import async_session_maker # noqa: E402
from app.models.gateways import Gateway # noqa: E402
from app.services.template_sync import sync_gateway_templates # noqa: E402
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@@ -59,6 +54,13 @@ def _parse_args() -> argparse.Namespace:
async def _run() -> int: async def _run() -> int:
from app.db.session import async_session_maker
from app.models.gateways import Gateway
from app.services.template_sync import (
GatewayTemplateSyncOptions,
sync_gateway_templates,
)
args = _parse_args() args = _parse_args()
gateway_id = UUID(args.gateway_id) gateway_id = UUID(args.gateway_id)
board_id = UUID(args.board_id) if args.board_id else None board_id = UUID(args.board_id) if args.board_id else None
@@ -72,12 +74,14 @@ async def _run() -> int:
result = await sync_gateway_templates( result = await sync_gateway_templates(
session, session,
gateway, gateway,
options=GatewayTemplateSyncOptions(
user=None, user=None,
include_main=bool(args.include_main), include_main=bool(args.include_main),
reset_sessions=bool(args.reset_sessions), reset_sessions=bool(args.reset_sessions),
rotate_tokens=bool(args.rotate_tokens), rotate_tokens=bool(args.rotate_tokens),
force_bootstrap=bool(args.force_bootstrap), force_bootstrap=bool(args.force_bootstrap),
board_id=board_id, board_id=board_id,
),
) )
sys.stdout.write(f"gateway_id={result.gateway_id}\n") sys.stdout.write(f"gateway_id={result.gateway_id}\n")