diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py new file mode 100644 index 00000000..96540569 --- /dev/null +++ b/backend/app/api/agent.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, select + +from app.api import agents as agents_api +from app.api import approvals as approvals_api +from app.api import board_memory as board_memory_api +from app.api import board_onboarding as onboarding_api +from app.api import tasks as tasks_api +from app.api.deps import ActorContext, get_board_or_404, get_task_or_404 +from app.core.agent_auth import AgentAuthContext, get_agent_auth_context +from app.db.session import get_session +from app.models.boards import Board +from app.schemas.approvals import ApprovalCreate, ApprovalRead +from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead +from app.schemas.board_onboarding import BoardOnboardingRead +from app.schemas.boards import BoardRead +from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskRead, TaskUpdate +from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentRead + +router = APIRouter(prefix="/agent", tags=["agent"]) + + +def _actor(agent_ctx: AgentAuthContext) -> ActorContext: + return ActorContext(actor_type="agent", agent=agent_ctx.agent) + + +def _guard_board_access(agent_ctx: AgentAuthContext, board: Board) -> None: + if agent_ctx.agent.board_id and agent_ctx.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + +@router.get("/boards", response_model=list[BoardRead]) +def list_boards( + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[Board]: + if agent_ctx.agent.board_id: + board = session.get(Board, agent_ctx.agent.board_id) + return [board] if board else [] + return list(session.exec(select(Board))) + + +@router.get("/boards/{board_id}", response_model=BoardRead) +def get_board( + board: Board = Depends(get_board_or_404), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> Board: + _guard_board_access(agent_ctx, board) + return board + + +@router.get("/boards/{board_id}/tasks", response_model=list[TaskRead]) +def list_tasks( + status_filter: str | None = Query(default=None, alias="status"), + assigned_agent_id: UUID | None = None, + unassigned: bool | None = None, + limit: int | None = Query(default=None, ge=1, le=200), + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[TaskRead]: + _guard_board_access(agent_ctx, board) + return tasks_api.list_tasks( + status_filter=status_filter, + assigned_agent_id=assigned_agent_id, + unassigned=unassigned, + limit=limit, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.patch("/boards/{board_id}/tasks/{task_id}", response_model=TaskRead) +def update_task( + payload: TaskUpdate, + task=Depends(get_task_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> TaskRead: + if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return tasks_api.update_task( + payload=payload, + task=task, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.get("/boards/{board_id}/tasks/{task_id}/comments", response_model=list[TaskCommentRead]) +def list_task_comments( + task=Depends(get_task_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[TaskCommentRead]: + if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return tasks_api.list_task_comments( + task=task, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/boards/{board_id}/tasks/{task_id}/comments", response_model=TaskCommentRead) +def create_task_comment( + payload: TaskCommentCreate, + task=Depends(get_task_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> TaskCommentRead: + if agent_ctx.agent.board_id and task.board_id and agent_ctx.agent.board_id != task.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return tasks_api.create_task_comment( + payload=payload, + task=task, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.get("/boards/{board_id}/memory", response_model=list[BoardMemoryRead]) +def list_board_memory( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[BoardMemoryRead]: + _guard_board_access(agent_ctx, board) + return board_memory_api.list_board_memory( + limit=limit, + offset=offset, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/boards/{board_id}/memory", response_model=BoardMemoryRead) +def create_board_memory( + payload: BoardMemoryCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> BoardMemoryRead: + _guard_board_access(agent_ctx, board) + return board_memory_api.create_board_memory( + payload=payload, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.get("/boards/{board_id}/approvals", response_model=list[ApprovalRead]) +def list_approvals( + status_filter: str | None = Query(default=None, alias="status"), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> list[ApprovalRead]: + _guard_board_access(agent_ctx, board) + return approvals_api.list_approvals( + status_filter=status_filter, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/boards/{board_id}/approvals", response_model=ApprovalRead) +def create_approval( + payload: ApprovalCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> ApprovalRead: + _guard_board_access(agent_ctx, board) + return approvals_api.create_approval( + payload=payload, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/boards/{board_id}/onboarding", response_model=BoardOnboardingRead) +def update_onboarding( + payload: dict[str, object], + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> BoardOnboardingRead: + _guard_board_access(agent_ctx, board) + return onboarding_api.agent_onboarding_update( + payload=payload, + board=board, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/agents", response_model=AgentRead) +def create_agent( + payload: AgentCreate, + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> AgentRead: + if not agent_ctx.agent.is_board_lead: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + if not agent_ctx.agent.board_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + payload = AgentCreate(**{**payload.model_dump(), "board_id": agent_ctx.agent.board_id}) + return agents_api.create_agent( + payload=payload, + session=session, + actor=_actor(agent_ctx), + ) + + +@router.post("/heartbeat", response_model=AgentRead) +async def agent_heartbeat( + payload: AgentHeartbeatCreate, + session: Session = Depends(get_session), + agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), +) -> AgentRead: + if agent_ctx.agent.name != payload.name: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined] + payload=payload, + session=session, + actor=_actor(agent_ctx), + ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 0d2ea5fb..4a4bd74b 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -301,6 +301,7 @@ def get_agent( async def update_agent( agent_id: str, payload: AgentUpdate, + force: bool = False, session: Session = Depends(get_session), auth: AuthContext = Depends(require_admin_auth), ) -> Agent: @@ -321,7 +322,7 @@ async def update_agent( updates["identity_profile"] = _normalize_identity_profile( updates.get("identity_profile") ) - if not updates: + if not updates and not force: return _with_computed_status(agent) if "board_id" in updates: _require_board(session, updates["board_id"]) @@ -380,9 +381,17 @@ async def update_agent( except OpenClawGatewayError as exc: _record_instruction_failure(session, agent, str(exc), "update") session.commit() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Gateway update failed: {exc}", + ) from exc except Exception as exc: # pragma: no cover - unexpected provisioning errors _record_instruction_failure(session, agent, str(exc), "update") session.commit() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error updating agent provisioning.", + ) from exc return _with_computed_status(agent) diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py index fd53648f..8ec593bb 100644 --- a/backend/app/api/board_onboarding.py +++ b/backend/app/api/board_onboarding.py @@ -6,20 +6,16 @@ from datetime import datetime from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status +import logging from sqlmodel import Session, select -from app.api.deps import get_board_or_404, require_admin_auth +from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent from app.core.agent_tokens import generate_agent_token, hash_agent_token from app.core.auth import AuthContext from app.core.config import settings from app.db.session import get_session from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig -from app.integrations.openclaw_gateway import ( - OpenClawGatewayError, - ensure_session, - get_chat_history, - send_message, -) +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, send_message from app.models.agents import Agent from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board @@ -34,59 +30,8 @@ from app.schemas.boards import BoardRead from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, provision_agent router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"]) +logger = logging.getLogger(__name__) -def _extract_json(text: str) -> dict[str, object] | None: - try: - return json.loads(text.strip()) - except Exception: - pass - match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) - if match: - try: - return json.loads(match.group(1).strip()) - except Exception: - pass - first = text.find("{") - last = text.rfind("}") - if first != -1 and last > first: - try: - return json.loads(text[first : last + 1]) - except Exception: - return None - return None - - -def _extract_text(content: object) -> str | None: - if isinstance(content, str): - return content - if isinstance(content, list): - for entry in content: - if isinstance(entry, dict) and entry.get("type") == "text": - text = entry.get("text") - if isinstance(text, str): - return text - if isinstance(content, dict): - text = content.get("text") - if isinstance(text, str): - return text - return None - - -def _get_assistant_messages(history: object) -> list[str]: - messages: list[str] = [] - if isinstance(history, dict): - history = history.get("messages") - if not isinstance(history, list): - return messages - for msg in history: - if not isinstance(msg, dict): - continue - if msg.get("role") != "assistant": - continue - text = _extract_text(msg.get("content")) - if text: - messages.append(text) - return messages def _gateway_config(session: Session, board: Board) -> tuple[Gateway, GatewayClientConfig]: @@ -104,7 +49,11 @@ def _build_session_key(agent_name: str) -> str: def _lead_agent_name(board: Board) -> str: - return f"{board.name} Lead" + return "Lead Agent" + + +def _lead_session_key(board: Board) -> str: + return f"agent:lead-{board.id}:main" async def _ensure_lead_agent( @@ -120,6 +69,11 @@ async def _ensure_lead_agent( .where(Agent.is_board_lead.is_(True)) ).first() if existing: + if existing.name != _lead_agent_name(board): + existing.name = _lead_agent_name(board) + session.add(existing) + session.commit() + session.refresh(existing) return existing agent = Agent( @@ -131,14 +85,14 @@ async def _ensure_lead_agent( identity_profile={ "role": "Board Lead", "communication_style": "direct, concise, practical", - "emoji": ":compass:", + "emoji": ":gear:", }, ) raw_token = generate_agent_token() agent.agent_token_hash = hash_agent_token(raw_token) agent.provision_requested_at = datetime.utcnow() agent.provision_action = "provision" - agent.openclaw_session_id = _build_session_key(agent.name) + agent.openclaw_session_id = _lead_session_key(board) session.add(agent) session.commit() session.refresh(agent) @@ -200,17 +154,28 @@ async def start_onboarding( "BOARD ONBOARDING REQUEST\n\n" f"Board Name: {board.name}\n" "You are the main agent. Ask the user 3-6 focused questions to clarify their goal.\n" - "Only respond in OpenClaw chat with onboarding JSON. All other outputs must be sent to Mission Control via API.\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 MAIN_USER.md or MAIN_TOOLS.md and pass it as X-Agent-Token.\n" - "Example API call (for non-onboarding updates):\n" - f"curl -s -X POST \"{base_url}/api/v1/boards/{board.id}/memory\" " + "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 '{\"content\":\"Onboarding update...\",\"tags\":[\"onboarding\"],\"source\":\"main_agent\"}'\n" - "Return questions as JSON: {\"question\": \"...\", \"options\": [...]}.\n" - "When you have enough info, return JSON: {\"status\": \"complete\", \"board_type\": \"goal\"|\"general\", " - "\"objective\": \"...\", \"success_metrics\": {...}, \"target_date\": \"YYYY-MM-DD\"}." + "-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\":{...},\"target_date\":\"YYYY-MM-DD\"}'\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, return JSON ONLY (via API):\n" + "{\"status\":\"complete\",\"board_type\":\"goal\"|\"general\",\"objective\":\"...\"," + "\"success_metrics\":{...},\"target_date\":\"YYYY-MM-DD\"}." ) try: @@ -251,7 +216,7 @@ async def answer_onboarding( if payload.other_text: answer_text = f"{payload.answer}: {payload.other_text}" - messages = onboarding.messages or [] + messages = list(onboarding.messages or []) messages.append( {"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()} ) @@ -261,21 +226,9 @@ async def answer_onboarding( await send_message( answer_text, session_key=onboarding.session_key, config=config, deliver=False ) - history = await get_chat_history(onboarding.session_key, config=config) except OpenClawGatewayError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc - assistant_messages = _get_assistant_messages(history) - if assistant_messages: - last = assistant_messages[-1] - messages.append( - {"role": "assistant", "content": last, "timestamp": datetime.utcnow().isoformat()} - ) - parsed = _extract_json(last) - if parsed and parsed.get("status") == "complete": - onboarding.draft_goal = parsed - onboarding.status = "completed" - onboarding.messages = messages onboarding.updated_at = datetime.utcnow() session.add(onboarding) @@ -284,6 +237,70 @@ async def answer_onboarding( return onboarding +@router.post("/agent", response_model=BoardOnboardingRead) +def agent_onboarding_update( + payload: dict[str, object], + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> BoardOnboardingSession: + if actor.actor_type != "agent" or actor.agent is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + agent = actor.agent + if agent.board_id is not None: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + if board.gateway_id: + gateway = session.get(Gateway, board.gateway_id) + if gateway and gateway.main_session_key and agent.openclaw_session_id: + if agent.openclaw_session_id != gateway.main_session_key: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + 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 = datetime.utcnow().isoformat() + payload_text = json.dumps(payload) + logger.info( + "onboarding.agent.update board_id=%s agent_id=%s payload=%s", + board.id, + agent.id, + payload_text, + ) + payload_status = payload.get("status") + if payload_status == "complete": + onboarding.draft_goal = payload + onboarding.status = "completed" + messages.append({"role": "assistant", "content": payload_text, "timestamp": now}) + else: + question = payload.get("question") + options = payload.get("options") + if not isinstance(question, str) or not isinstance(options, list): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + messages.append({"role": "assistant", "content": payload_text, "timestamp": now}) + + onboarding.messages = messages + onboarding.updated_at = datetime.utcnow() + session.add(onboarding) + session.commit() + 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, diff --git a/backend/app/api/gateways.py b/backend/app/api/gateways.py index df7f779d..8c6efc94 100644 --- a/backend/app/api/gateways.py +++ b/backend/app/api/gateways.py @@ -311,7 +311,7 @@ async def _ensure_main_agent( await send_message( ( f"Hello {agent.name}. Your gateway provisioning was updated.\n\n" - "Please re-read MAIN_AGENTS.md, MAIN_USER.md, MAIN_HEARTBEAT.md, and MAIN_TOOLS.md. " + "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." ), session_key=gateway.main_session_key, diff --git a/backend/app/core/agent_auth.py b/backend/app/core/agent_auth.py index 2fc0afa6..2538461c 100644 --- a/backend/app/core/agent_auth.py +++ b/backend/app/core/agent_auth.py @@ -3,13 +3,16 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, Header, HTTPException, Request, status +import logging from sqlmodel import Session, col, select from app.core.agent_tokens import verify_agent_token from app.db.session import get_session from app.models.agents import Agent +logger = logging.getLogger(__name__) + @dataclass class AgentAuthContext: @@ -25,25 +28,78 @@ def _find_agent_for_token(session: Session, token: str) -> Agent | None: return None +def _resolve_agent_token( + agent_token: str | None, + authorization: str | None, + *, + accept_authorization: bool = True, +) -> str | None: + if agent_token: + return agent_token + if not accept_authorization: + return None + if not authorization: + return None + value = authorization.strip() + if not value: + return None + if value.lower().startswith("bearer "): + return value.split(" ", 1)[1].strip() or None + return None + + def get_agent_auth_context( + request: Request, agent_token: str | None = Header(default=None, alias="X-Agent-Token"), + authorization: str | None = Header(default=None, alias="Authorization"), session: Session = Depends(get_session), ) -> AgentAuthContext: - if not agent_token: + resolved = _resolve_agent_token(agent_token, authorization, accept_authorization=True) + if not resolved: + logger.warning( + "agent auth missing token path=%s x_agent=%s authorization=%s", + request.url.path, + bool(agent_token), + bool(authorization), + ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - agent = _find_agent_for_token(session, agent_token) + agent = _find_agent_for_token(session, resolved) if agent is None: + logger.warning( + "agent auth invalid token path=%s token_prefix=%s", + request.url.path, + resolved[:6], + ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return AgentAuthContext(actor_type="agent", agent=agent) def get_agent_auth_context_optional( + request: Request, agent_token: str | None = Header(default=None, alias="X-Agent-Token"), + authorization: str | None = Header(default=None, alias="Authorization"), session: Session = Depends(get_session), ) -> AgentAuthContext | None: - if not agent_token: + resolved = _resolve_agent_token( + agent_token, + authorization, + accept_authorization=False, + ) + if not resolved: + if agent_token: + logger.warning( + "agent auth optional missing token path=%s x_agent=%s authorization=%s", + request.url.path, + bool(agent_token), + bool(authorization), + ) return None - agent = _find_agent_for_token(session, agent_token) + agent = _find_agent_for_token(session, resolved) if agent is None: + logger.warning( + "agent auth optional invalid token path=%s token_prefix=%s", + request.url.path, + resolved[:6], + ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) return AgentAuthContext(actor_type="agent", agent=agent) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py index 005c9cf5..14fae05a 100644 --- a/backend/app/core/auth.py +++ b/backend/app/core/auth.py @@ -112,17 +112,17 @@ async def get_auth_context_optional( clerk_credentials = await guard(request) except (RuntimeError, ValueError) as exc: raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc - except HTTPException as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc + except HTTPException: + return None auth_data = _resolve_clerk_auth(request, clerk_credentials) try: clerk_user_id = _parse_subject(auth_data) - except ValidationError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc + except ValidationError: + return None if not clerk_user_id: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) + return None user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first() if user is None: diff --git a/backend/app/main.py b/backend/app/main.py index 7c5a5060..a1e36b8e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.activity import router as activity_router +from app.api.agent import router as agent_router from app.api.agents import router as agents_router from app.api.approvals import router as approvals_router from app.api.auth import router as auth_router @@ -56,6 +57,7 @@ def readyz() -> dict[str, bool]: api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(auth_router) +api_v1.include_router(agent_router) api_v1.include_router(agents_router) api_v1.include_router(activity_router) api_v1.include_router(gateway_router) diff --git a/backend/app/schemas/agents.py b/backend/app/schemas/agents.py index 238b775c..56bf9a5d 100644 --- a/backend/app/schemas/agents.py +++ b/backend/app/schemas/agents.py @@ -33,6 +33,7 @@ class AgentUpdate(SQLModel): class AgentRead(AgentBase): id: UUID + is_board_lead: bool = False openclaw_session_id: str | None = None last_seen_at: datetime | None created_at: datetime diff --git a/frontend/src/app/agents/[agentId]/edit/page.tsx b/frontend/src/app/agents/[agentId]/edit/page.tsx index b15af721..1576fa31 100644 --- a/frontend/src/app/agents/[agentId]/edit/page.tsx +++ b/frontend/src/app/agents/[agentId]/edit/page.tsx @@ -201,7 +201,9 @@ export default function EditAgentPage() { setError(null); try { const token = await getToken(); - const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, { + const response = await fetch( + `${apiBase}/api/v1/agents/${agentId}?force=true`, + { method: "PATCH", headers: { "Content-Type": "application/json", @@ -217,7 +219,8 @@ export default function EditAgentPage() { identity_profile: normalizeIdentityProfile(identityProfile), soul_template: soulTemplate.trim() || null, }), - }); + } + ); if (!response.ok) { throw new Error("Unable to update agent."); } diff --git a/frontend/src/app/agents/[agentId]/page.tsx b/frontend/src/app/agents/[agentId]/page.tsx index e68e46f5..0aed80a0 100644 --- a/frontend/src/app/agents/[agentId]/page.tsx +++ b/frontend/src/app/agents/[agentId]/page.tsx @@ -31,6 +31,7 @@ type Agent = { created_at: string; updated_at: string; board_id?: string | null; + is_board_lead?: boolean; }; type Board = { @@ -106,6 +107,7 @@ export default function AgentDetailPage() { return boards.find((board) => board.id === agent.board_id) ?? null; }, [boards, agent?.board_id]); + const loadAgent = async () => { if (!isSignedIn || !agentId) return; setIsLoading(true); diff --git a/frontend/src/app/agents/page.tsx b/frontend/src/app/agents/page.tsx index b8364616..724a44bf 100644 --- a/frontend/src/app/agents/page.tsx +++ b/frontend/src/app/agents/page.tsx @@ -38,6 +38,7 @@ type Agent = { created_at: string; updated_at: string; board_id?: string | null; + is_board_lead?: boolean; }; type Board = { @@ -138,6 +139,7 @@ export default function AgentsPage() { const sortedAgents = useMemo(() => [...agents], [agents]); + const handleDelete = () => { if (!deleteTarget) return; deleteMutation.mutate(deleteTarget); diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index 7f1f8a8e..12f517c0 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -5,9 +5,13 @@ import { useParams, useRouter } from "next/navigation"; import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs"; +import { BoardApprovalsPanel } from "@/components/BoardApprovalsPanel"; +import { BoardGoalPanel } from "@/components/BoardGoalPanel"; +import { BoardOnboardingChat } from "@/components/BoardOnboardingChat"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, @@ -74,6 +78,7 @@ export default function EditBoardPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [metricsError, setMetricsError] = useState(null); + const [isOnboardingOpen, setIsOnboardingOpen] = useState(false); const isFormReady = Boolean(name.trim() && gatewayId); @@ -123,6 +128,17 @@ export default function EditBoardPage() { } }; + const handleOnboardingConfirmed = (updated: Board) => { + setBoard(updated); + setBoardType(updated.board_type ?? "goal"); + setObjective(updated.objective ?? ""); + setSuccessMetrics( + updated.success_metrics ? JSON.stringify(updated.success_metrics, null, 2) : "" + ); + setTargetDate(toDateInput(updated.target_date)); + setIsOnboardingOpen(false); + }; + useEffect(() => { loadBoard(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -199,7 +215,8 @@ export default function EditBoardPage() { }; return ( - + <> +
@@ -229,126 +246,152 @@ export default function EditBoardPage() {
-
-
-
- - setName(event.target.value)} - placeholder="Board name" - disabled={isLoading || !board} - /> -
-
- - -
-
- -
-
- - -
-
- - setTargetDate(event.target.value)} - disabled={isLoading} - /> -
-
- -
- -