feat: enhance agent heartbeat handling and validate onboarding goal fields
This commit is contained in:
@@ -20,7 +20,7 @@ from app.models.agents import Agent
|
|||||||
from app.models.boards import Board
|
from app.models.boards import Board
|
||||||
from app.models.gateways import Gateway
|
from app.models.gateways import Gateway
|
||||||
from app.models.tasks import Task
|
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.approvals import ApprovalCreate, ApprovalRead
|
||||||
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead
|
||||||
from app.schemas.board_onboarding import BoardOnboardingRead
|
from app.schemas.board_onboarding import BoardOnboardingRead
|
||||||
@@ -376,14 +376,10 @@ async def agent_heartbeat(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
|
||||||
) -> AgentRead:
|
) -> AgentRead:
|
||||||
if agent_ctx.agent.name != payload.name:
|
# Heartbeats must apply to the authenticated agent; agent names are not unique.
|
||||||
payload = AgentHeartbeatCreate(
|
return agents_api.heartbeat_agent( # type: ignore[attr-defined]
|
||||||
name=agent_ctx.agent.name,
|
agent_id=str(agent_ctx.agent.id),
|
||||||
status=payload.status,
|
payload=AgentHeartbeat(status=payload.status),
|
||||||
board_id=payload.board_id,
|
|
||||||
)
|
|
||||||
return await agents_api.heartbeat_or_create_agent( # type: ignore[attr-defined]
|
|
||||||
payload=payload,
|
|
||||||
session=session,
|
session=session,
|
||||||
actor=_actor(agent_ctx),
|
actor=_actor(agent_ctx),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -588,7 +588,7 @@ def heartbeat_agent(
|
|||||||
payload: AgentHeartbeat,
|
payload: AgentHeartbeat,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> Agent:
|
) -> AgentRead:
|
||||||
agent = session.get(Agent, agent_id)
|
agent = session.get(Agent, agent_id)
|
||||||
if agent is None:
|
if agent is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
@@ -613,8 +613,20 @@ async def heartbeat_or_create_agent(
|
|||||||
payload: AgentHeartbeatCreate,
|
payload: AgentHeartbeatCreate,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
actor: ActorContext = Depends(require_admin_or_agent),
|
actor: ActorContext = Depends(require_admin_or_agent),
|
||||||
) -> Agent:
|
) -> AgentRead:
|
||||||
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first()
|
# 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 agent is None:
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|||||||
@@ -544,6 +544,20 @@ def update_task(
|
|||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(task)
|
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
|
return task
|
||||||
if actor.actor_type == "agent":
|
if actor.actor_type == "agent":
|
||||||
if actor.agent and actor.agent.board_id and task.board_id:
|
if actor.agent and actor.agent.board_id and task.board_id:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +22,13 @@ class BoardOnboardingConfirm(SQLModel):
|
|||||||
success_metrics: dict[str, object] | None = None
|
success_metrics: dict[str, object] | None = None
|
||||||
target_date: datetime | 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):
|
class BoardOnboardingRead(SQLModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.schemas.board_onboarding import BoardOnboardingConfirm
|
||||||
from app.schemas.boards import BoardCreate
|
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():
|
def test_general_board_allows_missing_objective():
|
||||||
BoardCreate(name="General", slug="general", board_type="general")
|
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")
|
||||||
|
|||||||
@@ -2066,6 +2066,15 @@ export default function BoardDetailPage() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={chatInput}
|
value={chatInput}
|
||||||
onChange={(event) => setChatInput(event.target.value)}
|
onChange={(event) => setChatInput(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
if (event.nativeEvent.isComposing) return;
|
||||||
|
if (event.shiftKey) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (isChatSending) return;
|
||||||
|
if (!chatInput.trim()) return;
|
||||||
|
void handleSendChat();
|
||||||
|
}}
|
||||||
placeholder="Message the board lead. Tag agents with @name."
|
placeholder="Message the board lead. Tag agents with @name."
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ export function BoardOnboardingChat({
|
|||||||
|
|
||||||
const submitAnswer = useCallback(() => {
|
const submitAnswer = useCallback(() => {
|
||||||
const trimmedOther = otherText.trim();
|
const trimmedOther = otherText.trim();
|
||||||
|
if (selectedOptions.length === 0 && !trimmedOther) return;
|
||||||
const answer =
|
const answer =
|
||||||
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
|
selectedOptions.length > 0 ? selectedOptions.join(", ") : "Other";
|
||||||
if (!answer && !trimmedOther) return;
|
|
||||||
void handleAnswer(answer, trimmedOther || undefined);
|
void handleAnswer(answer, trimmedOther || undefined);
|
||||||
}, [handleAnswer, otherText, selectedOptions]);
|
}, [handleAnswer, otherText, selectedOptions]);
|
||||||
|
|
||||||
@@ -294,6 +294,12 @@ export function BoardOnboardingChat({
|
|||||||
placeholder="Other..."
|
placeholder="Other..."
|
||||||
value={otherText}
|
value={otherText}
|
||||||
onChange={(event) => setOtherText(event.target.value)}
|
onChange={(event) => setOtherText(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (loading) return;
|
||||||
|
submitAnswer();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Reference in New Issue
Block a user