feat(boards): implement lead notification on board updates with detailed change messages
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user