These dependencies check actor type (human user vs agent), not admin privilege. The old names were misleading and could cause authorization mistakes when wiring new endpoints. Renamed across all 10 consumer files along with their local ADMIN_AUTH_DEP / ADMIN_OR_AGENT_DEP aliases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
475 lines
17 KiB
Python
475 lines
17 KiB
Python
"""Board onboarding endpoints for user/agent collaboration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import ValidationError
|
|
from sqlmodel import col
|
|
|
|
from app.api.deps import (
|
|
ActorContext,
|
|
get_board_for_user_read,
|
|
get_board_for_user_write,
|
|
get_board_or_404,
|
|
require_user_auth,
|
|
require_user_or_agent,
|
|
)
|
|
from app.core.config import settings
|
|
from app.core.logging import get_logger
|
|
from app.core.time import utcnow
|
|
from app.db.session import get_session
|
|
from app.models.board_onboarding import BoardOnboardingSession
|
|
from app.schemas.board_onboarding import (
|
|
BoardOnboardingAgentComplete,
|
|
BoardOnboardingAgentUpdate,
|
|
BoardOnboardingAnswer,
|
|
BoardOnboardingConfirm,
|
|
BoardOnboardingLeadAgentDraft,
|
|
BoardOnboardingRead,
|
|
BoardOnboardingStart,
|
|
BoardOnboardingUserProfile,
|
|
)
|
|
from app.schemas.boards import BoardRead
|
|
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
|
|
from app.services.openclaw.gateway_resolver import get_gateway_for_board
|
|
from app.services.openclaw.onboarding_service import BoardOnboardingMessagingService
|
|
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
|
from app.services.openclaw.provisioning_db import (
|
|
LeadAgentOptions,
|
|
LeadAgentRequest,
|
|
OpenClawProvisioningService,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
from app.core.auth import AuthContext
|
|
from app.models.boards import Board
|
|
|
|
router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"])
|
|
logger = get_logger(__name__)
|
|
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
|
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
BOARD_OR_404_DEP = Depends(get_board_or_404)
|
|
SESSION_DEP = Depends(get_session)
|
|
ACTOR_DEP = Depends(require_user_or_agent)
|
|
USER_AUTH_DEP = Depends(require_user_auth)
|
|
|
|
|
|
def _parse_draft_user_profile(
|
|
draft_goal: object,
|
|
) -> BoardOnboardingUserProfile | None:
|
|
if not isinstance(draft_goal, dict):
|
|
return None
|
|
raw_profile = draft_goal.get("user_profile")
|
|
if raw_profile is None:
|
|
return None
|
|
try:
|
|
return BoardOnboardingUserProfile.model_validate(raw_profile)
|
|
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 _normalize_autonomy_token(value: object) -> str | None:
|
|
if not isinstance(value, str):
|
|
return None
|
|
text = value.strip().lower()
|
|
if not text:
|
|
return None
|
|
return text.replace("_", "-")
|
|
|
|
|
|
def _is_fully_autonomous_choice(value: object) -> bool:
|
|
token = _normalize_autonomy_token(value)
|
|
if token is None:
|
|
return False
|
|
if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
|
|
return True
|
|
return "autonom" in token and "fully" in token
|
|
|
|
|
|
def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
|
|
"""Enable done-approval gate unless onboarding selected fully autonomous mode."""
|
|
if not isinstance(draft_goal, dict):
|
|
return True
|
|
raw_lead = draft_goal.get("lead_agent")
|
|
if not isinstance(raw_lead, dict):
|
|
return True
|
|
if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
|
|
return False
|
|
raw_identity_profile = raw_lead.get("identity_profile")
|
|
if isinstance(raw_identity_profile, dict):
|
|
for key in ("autonomy_level", "autonomy", "mode"):
|
|
if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
|
|
return False
|
|
return True
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
@router.get("", response_model=BoardOnboardingRead)
|
|
async def get_onboarding(
|
|
board: Board = BOARD_USER_READ_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardOnboardingSession:
|
|
"""Get the latest onboarding session for a board."""
|
|
onboarding = (
|
|
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
.first(session)
|
|
)
|
|
if onboarding is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
return onboarding
|
|
|
|
|
|
@router.post("/start", response_model=BoardOnboardingRead)
|
|
async def start_onboarding(
|
|
_payload: BoardOnboardingStart,
|
|
board: Board = BOARD_USER_WRITE_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardOnboardingSession:
|
|
"""Start onboarding and send instructions to the gateway agent."""
|
|
onboarding = (
|
|
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
.filter(col(BoardOnboardingSession.status) == "active")
|
|
.first(session)
|
|
)
|
|
if onboarding:
|
|
last_user_content: str | None = None
|
|
messages = onboarding.messages or []
|
|
if messages:
|
|
last_message = messages[-1]
|
|
if isinstance(last_message, dict):
|
|
last_role = last_message.get("role")
|
|
content = last_message.get("content")
|
|
if last_role == "user" and isinstance(content, str) and content:
|
|
last_user_content = content
|
|
|
|
if last_user_content:
|
|
# Retrigger the agent when the session is waiting on a response.
|
|
dispatcher = BoardOnboardingMessagingService(session)
|
|
await dispatcher.dispatch_answer(
|
|
board=board,
|
|
onboarding=onboarding,
|
|
answer_text=last_user_content,
|
|
correlation_id=f"onboarding.resume:{board.id}:{onboarding.id}",
|
|
)
|
|
onboarding.updated_at = utcnow()
|
|
session.add(onboarding)
|
|
await session.commit()
|
|
await session.refresh(onboarding)
|
|
return onboarding
|
|
|
|
dispatcher = BoardOnboardingMessagingService(session)
|
|
base_url = settings.base_url
|
|
prompt = (
|
|
"BOARD ONBOARDING REQUEST\n\n"
|
|
f"Board Name: {board.name}\n"
|
|
f"Board Description: {board.description or '(not provided)'}\n"
|
|
"You are the gateway 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"
|
|
" (communication style, autonomy, update cadence, and output formatting).\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"
|
|
" 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"
|
|
f"Mission Control base URL: {base_url}\n"
|
|
"Use the AUTH_TOKEN from USER.md or TOOLS.md and pass it as X-Agent-Token.\n"
|
|
"Onboarding response endpoint:\n"
|
|
f"POST {base_url}/api/v1/agent/boards/{board.id}/onboarding\n"
|
|
"QUESTION 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 \'{"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'
|
|
"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":"...","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"
|
|
)
|
|
|
|
session_key = await dispatcher.dispatch_start_prompt(
|
|
board=board,
|
|
prompt=prompt,
|
|
correlation_id=f"onboarding.start:{board.id}",
|
|
)
|
|
|
|
onboarding = BoardOnboardingSession(
|
|
board_id=board.id,
|
|
session_key=session_key,
|
|
status="active",
|
|
messages=[
|
|
{"role": "user", "content": prompt, "timestamp": utcnow().isoformat()},
|
|
],
|
|
)
|
|
session.add(onboarding)
|
|
await session.commit()
|
|
await session.refresh(onboarding)
|
|
return onboarding
|
|
|
|
|
|
@router.post("/answer", response_model=BoardOnboardingRead)
|
|
async def answer_onboarding(
|
|
payload: BoardOnboardingAnswer,
|
|
board: Board = BOARD_USER_WRITE_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardOnboardingSession:
|
|
"""Send a user onboarding answer to the gateway agent."""
|
|
onboarding = (
|
|
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
.first(session)
|
|
)
|
|
if onboarding is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
dispatcher = BoardOnboardingMessagingService(session)
|
|
answer_text = payload.answer
|
|
if payload.other_text:
|
|
answer_text = f"{payload.answer}: {payload.other_text}"
|
|
|
|
messages = list(onboarding.messages or [])
|
|
messages.append(
|
|
{"role": "user", "content": answer_text, "timestamp": utcnow().isoformat()},
|
|
)
|
|
|
|
await dispatcher.dispatch_answer(
|
|
board=board,
|
|
onboarding=onboarding,
|
|
answer_text=answer_text,
|
|
correlation_id=f"onboarding.answer:{board.id}:{onboarding.id}",
|
|
)
|
|
|
|
onboarding.messages = messages
|
|
onboarding.updated_at = utcnow()
|
|
session.add(onboarding)
|
|
await session.commit()
|
|
await session.refresh(onboarding)
|
|
return onboarding
|
|
|
|
|
|
@router.post("/agent", response_model=BoardOnboardingRead)
|
|
async def agent_onboarding_update(
|
|
payload: BoardOnboardingAgentUpdate,
|
|
board: Board = BOARD_OR_404_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
actor: ActorContext = ACTOR_DEP,
|
|
) -> BoardOnboardingSession:
|
|
"""Store onboarding updates submitted by the gateway agent."""
|
|
if actor.actor_type != "agent" or actor.agent is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
|
agent = actor.agent
|
|
OpenClawAuthorizationPolicy.require_gateway_scoped_actor(actor_agent=agent)
|
|
|
|
gateway = await get_gateway_for_board(session, board)
|
|
if gateway is not None:
|
|
OpenClawAuthorizationPolicy.require_gateway_main_actor_binding(
|
|
actor_agent=agent,
|
|
gateway=gateway,
|
|
)
|
|
|
|
onboarding = (
|
|
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
.first(session)
|
|
)
|
|
if onboarding is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
if onboarding.status == "confirmed":
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
|
|
|
|
messages = list(onboarding.messages or [])
|
|
now = utcnow().isoformat()
|
|
payload_text = payload.model_dump_json(exclude_none=True)
|
|
payload_data = payload.model_dump(mode="json", exclude_none=True)
|
|
logger.info(
|
|
"onboarding.agent.update board_id=%s agent_id=%s payload=%s",
|
|
board.id,
|
|
agent.id,
|
|
payload_text,
|
|
)
|
|
if isinstance(payload, BoardOnboardingAgentComplete):
|
|
onboarding.draft_goal = payload_data
|
|
onboarding.status = "completed"
|
|
messages.append(
|
|
{"role": "assistant", "content": payload_text, "timestamp": now},
|
|
)
|
|
else:
|
|
messages.append(
|
|
{"role": "assistant", "content": payload_text, "timestamp": now},
|
|
)
|
|
|
|
onboarding.messages = messages
|
|
onboarding.updated_at = utcnow()
|
|
session.add(onboarding)
|
|
await session.commit()
|
|
await session.refresh(onboarding)
|
|
logger.info(
|
|
"onboarding.agent.update stored board_id=%s messages_count=%s status=%s",
|
|
board.id,
|
|
len(onboarding.messages or []),
|
|
onboarding.status,
|
|
)
|
|
return onboarding
|
|
|
|
|
|
@router.post("/confirm", response_model=BoardRead)
|
|
async def confirm_onboarding(
|
|
payload: BoardOnboardingConfirm,
|
|
board: Board = BOARD_USER_WRITE_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
auth: AuthContext = USER_AUTH_DEP,
|
|
) -> Board:
|
|
"""Confirm onboarding results and provision the board lead agent."""
|
|
onboarding = (
|
|
await BoardOnboardingSession.objects.filter_by(board_id=board.id)
|
|
.order_by(col(BoardOnboardingSession.updated_at).desc())
|
|
.first(session)
|
|
)
|
|
if onboarding is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
|
|
|
board.board_type = payload.board_type
|
|
board.objective = payload.objective
|
|
board.success_metrics = payload.success_metrics
|
|
board.target_date = payload.target_date
|
|
board.goal_confirmed = True
|
|
board.goal_source = "lead_agent_onboarding"
|
|
board.require_approval_for_done = _require_approval_for_done_from_draft(
|
|
onboarding.draft_goal,
|
|
)
|
|
|
|
onboarding.status = "confirmed"
|
|
onboarding.updated_at = utcnow()
|
|
|
|
user_profile = _parse_draft_user_profile(onboarding.draft_goal)
|
|
if _apply_user_profile(auth, user_profile) and auth.user is not None:
|
|
session.add(auth.user)
|
|
|
|
lead_agent = _parse_draft_lead_agent(onboarding.draft_goal)
|
|
lead_options = _lead_agent_options(lead_agent)
|
|
|
|
gateway, config = await GatewayDispatchService(session).require_gateway_config_for_board(board)
|
|
session.add(board)
|
|
session.add(onboarding)
|
|
await session.commit()
|
|
await session.refresh(board)
|
|
await OpenClawProvisioningService(session).ensure_board_lead_agent(
|
|
request=LeadAgentRequest(
|
|
board=board,
|
|
gateway=gateway,
|
|
config=config,
|
|
user=auth.user,
|
|
options=lead_options,
|
|
),
|
|
)
|
|
return board
|