diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 01d874d1..041dee33 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, Literal, cast from uuid import UUID @@ -63,6 +65,43 @@ _ERR_GATEWAY_MAIN_AGENT_REQUIRED = ( ) +def _format_board_field_value(value: object) -> str: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, UUID): + return str(value) + if isinstance(value, dict): + return json.dumps(value, sort_keys=True, default=str) + if isinstance(value, bool): + return "true" if value else "false" + if value is None: + return "null" + return str(value) + + +def _board_update_message( + *, + board: Board, + changed_fields: dict[str, tuple[object, object]], +) -> str: + lines = [ + "BOARD UPDATED", + f"Board: {board.name}", + f"Board ID: {board.id}", + "", + "Changed fields:", + ] + for field_name in sorted(changed_fields): + previous, current = changed_fields[field_name] + lines.append( + f"- {field_name}: {_format_board_field_value(previous)}" + f" -> {_format_board_field_value(current)}" + ) + lines.append("") + lines.append("Take action: review the board changes and adjust plan/assignments as needed.") + return "\n".join(lines) + + async def _require_gateway_main_agent(session: AsyncSession, gateway: Gateway) -> None: main_agent = ( await Agent.objects.filter_by(gateway_id=gateway.id) @@ -366,6 +405,53 @@ async def _notify_agents_on_board_group_removal( ) +async def _notify_lead_on_board_update( + *, + session: AsyncSession, + board: Board, + changed_fields: dict[str, tuple[object, object]], +) -> None: + if not changed_fields: + return + lead = ( + await Agent.objects.filter_by(board_id=board.id) + .filter(col(Agent.is_board_lead).is_(True)) + .first(session) + ) + if lead is None or not lead.openclaw_session_id: + return + dispatch = GatewayDispatchService(session) + config = await dispatch.optional_gateway_config_for_board(board) + if config is None: + return + message = _board_update_message( + board=board, + changed_fields=changed_fields, + ) + error = await dispatch.try_send_agent_message( + session_key=lead.openclaw_session_id, + config=config, + agent_name=lead.name, + message=message, + deliver=False, + ) + if error is None: + record_activity( + session, + event_type="board.lead_notified", + message=f"Lead agent notified for board update: {board.name}.", + agent_id=lead.id, + ) + else: + record_activity( + session, + event_type="board.lead_notify_failed", + message=f"Lead board update notify failed for {board.name}: {error}", + agent_id=lead.id, + ) + await session.commit() + + @router.get("", response_model=DefaultLimitOffsetPage[BoardRead]) async def list_boards( gateway_id: UUID | None = GATEWAY_ID_QUERY, @@ -450,8 +536,19 @@ async def update_board( board: Board = BOARD_USER_WRITE_DEP, ) -> Board: """Update mutable board properties.""" + requested_updates = payload.model_dump(exclude_unset=True) + previous_values = { + field_name: getattr(board, field_name) + for field_name in requested_updates + if hasattr(board, field_name) + } previous_group_id = board.board_group_id updated = await _apply_board_update(payload=payload, session=session, board=board) + changed_fields = { + field_name: (previous_value, getattr(updated, field_name)) + for field_name, previous_value in previous_values.items() + if previous_value != getattr(updated, field_name) + } new_group_id = updated.board_group_id if previous_group_id is not None and previous_group_id != new_group_id: previous_group = await crud.get_by_id(session, BoardGroup, previous_group_id) @@ -483,6 +580,19 @@ async def update_board( updated.id, new_group_id, ) + if changed_fields: + try: + await _notify_lead_on_board_update( + session=session, + board=updated, + changed_fields=changed_fields, + ) + except (OpenClawGatewayError, OSError, RuntimeError, ValueError): + logger.exception( + "board.update.notify_lead_unexpected board_id=%s changed_fields=%s", + updated.id, + sorted(changed_fields), + ) return updated diff --git a/backend/tests/test_board_group_assignment_notifications.py b/backend/tests/test_board_group_assignment_notifications.py index 5c24fcc3..e7d481c8 100644 --- a/backend/tests/test_board_group_assignment_notifications.py +++ b/backend/tests/test_board_group_assignment_notifications.py @@ -69,11 +69,15 @@ async def test_update_board_notifies_agents_when_added_to_group( async def _fake_notify(**_kwargs: Any) -> None: calls["notify"] += 1 + async def _fake_lead_notify(**_kwargs: Any) -> None: + return None + async def _fake_get_by_id(*_args: Any, **_kwargs: Any) -> BoardGroup: return group monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_notify) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id) updated = await boards.update_board( @@ -108,12 +112,16 @@ async def test_update_board_notifies_agents_when_removed_from_group( async def _fake_leave(**_kwargs: Any) -> None: calls["leave"] += 1 + async def _fake_lead_notify(**_kwargs: Any) -> None: + return None + async def _fake_get_by_id(*_args: Any, **_kwargs: Any) -> BoardGroup: return group monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_join) monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_leave) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id) updated = await boards.update_board( @@ -151,6 +159,9 @@ async def test_update_board_notifies_agents_when_moved_between_groups( async def _fake_leave(**_kwargs: Any) -> None: calls["leave"] += 1 + async def _fake_lead_notify(**_kwargs: Any) -> None: + return None + async def _fake_get_by_id(_session: Any, _model: Any, obj_id: UUID) -> BoardGroup | None: if obj_id == old_group_id: return old_group @@ -161,6 +172,7 @@ async def test_update_board_notifies_agents_when_moved_between_groups( monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_join) monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_leave) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) monkeypatch.setattr(boards.crud, "get_by_id", _fake_get_by_id) updated = await boards.update_board( @@ -192,9 +204,13 @@ async def test_update_board_does_not_notify_when_group_unchanged( async def _fake_notify(**_kwargs: Any) -> None: calls["notify"] += 1 + async def _fake_lead_notify(**_kwargs: Any) -> None: + return None + monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) monkeypatch.setattr(boards, "_notify_agents_on_board_group_addition", _fake_notify) monkeypatch.setattr(boards, "_notify_agents_on_board_group_removal", _fake_notify) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) updated = await boards.update_board( payload=payload, @@ -206,6 +222,66 @@ async def test_update_board_does_not_notify_when_group_unchanged( assert calls["notify"] == 0 +@pytest.mark.asyncio +async def test_update_board_notifies_lead_when_fields_change( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board = _board(board_group_id=None) + session = _FakeSession() + payload = BoardUpdate(name="Platform X") + calls: dict[str, object] = {"count": 0, "changes": {}} + + async def _fake_apply_board_update(**kwargs: Any) -> Board: + target: Board = kwargs["board"] + target.name = "Platform X" + return target + + async def _fake_lead_notify(**kwargs: Any) -> None: + calls["count"] = int(calls["count"]) + 1 + calls["changes"] = kwargs["changed_fields"] + + monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) + + updated = await boards.update_board( + payload=payload, + session=session, # type: ignore[arg-type] + board=board, + ) + + assert updated.name == "Platform X" + assert calls["count"] == 1 + assert calls["changes"] == {"name": ("Platform", "Platform X")} + + +@pytest.mark.asyncio +async def test_update_board_skips_lead_notify_when_no_effective_change( + monkeypatch: pytest.MonkeyPatch, +) -> None: + board = _board(board_group_id=None) + session = _FakeSession() + payload = BoardUpdate(name="Platform") + calls = {"lead_notify": 0} + + async def _fake_apply_board_update(**kwargs: Any) -> Board: + return kwargs["board"] + + async def _fake_lead_notify(**_kwargs: Any) -> None: + calls["lead_notify"] += 1 + + monkeypatch.setattr(boards, "_apply_board_update", _fake_apply_board_update) + monkeypatch.setattr(boards, "_notify_lead_on_board_update", _fake_lead_notify) + + updated = await boards.update_board( + payload=payload, + session=session, # type: ignore[arg-type] + board=board, + ) + + assert updated.name == "Platform" + assert calls["lead_notify"] == 0 + + @pytest.mark.asyncio async def test_notify_agents_on_board_group_addition_fanout_and_records_results( monkeypatch: pytest.MonkeyPatch,