diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index f813fa4d..33b757b8 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -59,7 +59,7 @@ ```mermaid graph TB - CEO["CEO (Human — Renzo)"] + CEO["CEO (Human)"] Intake["Intake / Prompter
(on-demand, human-only)"] Sec["Secretary
(on-demand, human-only)"] PRRev["PR Reviewer
(read-only reviewer)"] diff --git a/docs/map/_front.md b/docs/map/_front.md index 5b5deeba..88eeaa99 100644 --- a/docs/map/_front.md +++ b/docs/map/_front.md @@ -59,7 +59,7 @@ ```mermaid graph TB - CEO["CEO (Human — Renzo)"] + CEO["CEO (Human)"] Intake["Intake / Prompter
(on-demand, human-only)"] Sec["Secretary
(on-demand, human-only)"] PRRev["PR Reviewer
(read-only reviewer)"] diff --git a/roboco/services/settings.py b/roboco/services/settings.py index 9e4ce7de..04962eec 100644 --- a/roboco/services/settings.py +++ b/roboco/services/settings.py @@ -12,8 +12,9 @@ from typing import TYPE_CHECKING from sqlalchemy import select -from roboco.db.tables import SystemSettingTable +from roboco.db.tables import AgentRole, SystemSettingTable from roboco.services.base import BaseService +from roboco.services.repositories.query_helpers import get_agent_by_role if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession @@ -114,9 +115,11 @@ def _validate_update_id(value: str) -> None: _VALIDATORS = { "transcript_retention_days": _validate_retention_days, # The CEO's panel display name (header chip + Settings User Info card). - # No config/migration involved — an unset key just means the panel's - # own hardcoded "Renzo" default renders, same as transcript retention's - # client-side DEFAULT_RETENTION fallback. + # An unset key just means the panel's own hardcoded "Renzo" default + # renders, same as transcript retention's client-side DEFAULT_RETENTION + # fallback. When set, `SettingsService.set` also writes through to the + # CEO agent row's `name` so GET /api/agents (kanban/assignee pickers) + # reflects the same name instead of the seeded literal. "ceo_name": _validate_ceo_name, # Telegram inbound's getUpdates offset cursor. Not a feature flag (absent # from FEATURE_FLAGS/the panel card) but reuses this same validated KV @@ -173,8 +176,21 @@ class SettingsService(BaseService): self.session.add(SystemSettingTable(key=key, value=value)) else: existing.value = value + if key == "ceo_name": + await self._sync_ceo_agent_name(value.strip()) await self.session.flush() + async def _sync_ceo_agent_name(self, name: str) -> None: + """Write-through: keep the CEO agent row's `name` in sync with the setting. + + Same-transaction as the setting write so the two never disagree. + Resolution rides `get_agent_by_role` (earliest-created row wins), the + shared duplicate-tolerant lookup every singleton-role consumer uses. + """ + agent = await get_agent_by_role(self.session, AgentRole.CEO) + if agent is not None: + agent.name = name + async def all(self) -> dict[str, str]: """Return every stored setting as a ``{key: value}`` map.""" result = await self.session.execute(select(SystemSettingTable)) diff --git a/tests/unit/services/test_settings_service.py b/tests/unit/services/test_settings_service.py index b1cc8967..2923196b 100644 --- a/tests/unit/services/test_settings_service.py +++ b/tests/unit/services/test_settings_service.py @@ -2,9 +2,12 @@ from __future__ import annotations +from datetime import UTC, datetime from typing import Any +from uuid import uuid4 import pytest +from roboco.db.tables import AgentRole, AgentStatus, AgentTable from roboco.services.settings import ( SettingValidationError, get_settings_service, @@ -12,6 +15,29 @@ from roboco.services.settings import ( ) +def _ceo_agent(name: str = "Renzo", created_at: datetime | None = None) -> AgentTable: + """A minimal CEO-role agent row for write-through tests. + + `created_at` defaults to an ancient timestamp so this row is the + earliest-created CEO regardless of rows other suite tests may have + committed into the shared DB — the write-through targets the oldest. + """ + return AgentTable( + id=uuid4(), + name=name, + slug=f"ceo-{uuid4().hex[:6]}", + role=AgentRole.CEO, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="x", + capabilities=[], + permissions={}, + metrics={}, + created_at=created_at or datetime(2000, 1, 1, tzinfo=UTC), + ) + + def test_validate_setting_rejects_unknown_key() -> None: with pytest.raises(SettingValidationError): validate_setting("not_a_real_setting", "x") @@ -77,3 +103,57 @@ async def test_ceo_name_set_rejects_blank_value(db_session: Any) -> None: svc = get_settings_service(db_session) with pytest.raises(SettingValidationError): await svc.set("ceo_name", " ") + + +@pytest.mark.asyncio +async def test_ceo_name_set_writes_through_to_ceo_agent_row(db_session: Any) -> None: + agent = _ceo_agent() + db_session.add(agent) + await db_session.flush() + + svc = get_settings_service(db_session) + await svc.set("ceo_name", "Alice") + + await db_session.refresh(agent) + assert agent.name == "Alice" + + +@pytest.mark.asyncio +async def test_ceo_name_set_strips_whitespace_on_agent_row(db_session: Any) -> None: + agent = _ceo_agent() + db_session.add(agent) + await db_session.flush() + + svc = get_settings_service(db_session) + await svc.set("ceo_name", " Bob ") + + await db_session.refresh(agent) + assert agent.name == "Bob" + + +@pytest.mark.asyncio +async def test_ceo_name_set_with_no_ceo_agent_does_not_raise( + db_session: Any, +) -> None: + svc = get_settings_service(db_session) + await svc.set("ceo_name", "Alice") # no CEO row seeded — no-op, no crash + assert await svc.get("ceo_name") == "Alice" + + +@pytest.mark.asyncio +async def test_ceo_name_set_tolerates_duplicate_ceo_rows(db_session: Any) -> None: + """A second role=CEO row must not crash the write-through (no bare one_or_none).""" + older = _ceo_agent("Renzo", created_at=datetime(2000, 1, 1, tzinfo=UTC)) + db_session.add(older) + await db_session.flush() + newer = _ceo_agent("Renzo", created_at=datetime(2000, 1, 2, tzinfo=UTC)) + db_session.add(newer) + await db_session.flush() + + svc = get_settings_service(db_session) + await svc.set("ceo_name", "Alice") # must not raise MultipleResultsFound + + await db_session.refresh(older) + await db_session.refresh(newer) + assert older.name == "Alice" # earliest-created wins + assert newer.name == "Renzo" # the duplicate is left untouched