diff --git a/backend/app/api/agent.py b/backend/app/api/agent.py index 57bc6603..de6e76fc 100644 --- a/backend/app/api/agent.py +++ b/backend/app/api/agent.py @@ -20,7 +20,7 @@ from app.models.agents import Agent from app.models.boards import Board from app.models.gateways import Gateway from app.models.tasks import Task -from app.schemas.agents import AgentCreate, AgentHeartbeatCreate, AgentNudge, AgentRead +from app.schemas.agents import AgentCreate, AgentHeartbeat, AgentHeartbeatCreate, AgentNudge, AgentRead from app.schemas.approvals import ApprovalCreate, ApprovalRead from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead from app.schemas.board_onboarding import BoardOnboardingRead @@ -376,14 +376,10 @@ async def agent_heartbeat( session: Session = Depends(get_session), agent_ctx: AgentAuthContext = Depends(get_agent_auth_context), ) -> AgentRead: - if agent_ctx.agent.name != payload.name: - payload = AgentHeartbeatCreate( - name=agent_ctx.agent.name, - status=payload.status, - board_id=payload.board_id, - ) - return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined] - payload=payload, + # Heartbeats must apply to the authenticated agent; agent names are not unique. + return agents_api.heartbeat_agent( # type: ignore[attr-defined] + agent_id=str(agent_ctx.agent.id), + payload=AgentHeartbeat(status=payload.status), session=session, actor=_actor(agent_ctx), ) diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 2d1862cd..d3cf8e6e 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -588,7 +588,7 @@ def heartbeat_agent( payload: AgentHeartbeat, session: Session = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), -) -> Agent: +) -> AgentRead: agent = session.get(Agent, agent_id) if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) @@ -613,8 +613,20 @@ async def heartbeat_or_create_agent( payload: AgentHeartbeatCreate, session: Session = Depends(get_session), actor: ActorContext = Depends(require_admin_or_agent), -) -> Agent: - agent = session.exec(select(Agent).where(Agent.name == payload.name)).first() +) -> AgentRead: + # Agent tokens must heartbeat their authenticated agent record. Names are not unique. + if actor.actor_type == "agent" and actor.agent: + return heartbeat_agent( + agent_id=str(actor.agent.id), + payload=AgentHeartbeat(status=payload.status), + session=session, + actor=actor, + ) + + statement = select(Agent).where(Agent.name == payload.name) + if payload.board_id is not None: + statement = statement.where(Agent.board_id == payload.board_id) + agent = session.exec(statement).first() if agent is None: if actor.actor_type == "agent": raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4637fa5a..94e349e2 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -544,6 +544,20 @@ def update_task( ) session.commit() session.refresh(task) + + if task.assigned_agent_id and task.assigned_agent_id != previous_assigned: + if actor.actor_type == "agent" and actor.agent and task.assigned_agent_id == actor.agent.id: + return task + assigned_agent = session.get(Agent, task.assigned_agent_id) + if assigned_agent: + board = session.get(Board, task.board_id) if task.board_id else None + if board: + _notify_agent_on_task_assign( + session=session, + board=board, + task=task, + agent=assigned_agent, + ) return task if actor.actor_type == "agent": if actor.agent and actor.agent.board_id and task.board_id: diff --git a/backend/app/schemas/board_onboarding.py b/backend/app/schemas/board_onboarding.py index 5c28ca60..f8fade52 100644 --- a/backend/app/schemas/board_onboarding.py +++ b/backend/app/schemas/board_onboarding.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from uuid import UUID +from pydantic import model_validator from sqlmodel import SQLModel @@ -21,6 +22,13 @@ class BoardOnboardingConfirm(SQLModel): success_metrics: dict[str, object] | None = None target_date: datetime | None = None + @model_validator(mode="after") + def validate_goal_fields(self): + if self.board_type == "goal": + if not self.objective or not self.success_metrics: + raise ValueError("Confirmed goal boards require objective and success_metrics") + return self + class BoardOnboardingRead(SQLModel): id: UUID diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py index a8857cfe..6a2a08ee 100644 --- a/backend/tests/test_board_schema.py +++ b/backend/tests/test_board_schema.py @@ -1,5 +1,6 @@ import pytest +from app.schemas.board_onboarding import BoardOnboardingConfirm from app.schemas.boards import BoardCreate @@ -28,3 +29,22 @@ def test_goal_board_allows_missing_objective_before_confirmation(): def test_general_board_allows_missing_objective(): BoardCreate(name="General", slug="general", board_type="general") + + +def test_onboarding_confirm_requires_goal_fields(): + with pytest.raises(ValueError): + BoardOnboardingConfirm(board_type="goal") + + with pytest.raises(ValueError): + BoardOnboardingConfirm(board_type="goal", objective="Ship onboarding") + + with pytest.raises(ValueError): + BoardOnboardingConfirm(board_type="goal", success_metrics={"emails": 3}) + + BoardOnboardingConfirm( + board_type="goal", + objective="Ship onboarding", + success_metrics={"emails": 3}, + ) + + BoardOnboardingConfirm(board_type="general") diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 39a2533c..30b4aa1c 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -2066,6 +2066,15 @@ export default function BoardDetailPage() {