feat(boards): implement lead notification on board updates with detailed change messages

This commit is contained in:
Abhimanyu Saharan
2026-02-26 01:58:55 +05:30
parent bc71d5ba38
commit 348b0515ac
2 changed files with 186 additions and 0 deletions

View File

@@ -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

View File

@@ -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,