Files
roboco/tests/unit/gateway/test_content_actions_roadmap.py
T
e77c3b7a63 feat(board): Board Program registry — Phase 1 (engine, LEARN ledger, per-project scoping, panel) (#689)
* feat(board): Board Program registry — generic trigger/dedup/originate/LEARN engine

One registry (foundation/policy/board_programs.py) + one BoardProgramEngine +
one orchestrator loop replace the bespoke roadmap/spotlight loops, behavior-
preserved: same sources, dispatch routing, one-open-cycle dedup (ledger rows
auto-close when their exploration task goes terminal, so x_feature's
complete-at-propose flow can't wedge), and live per-program interval
overrides with the tick capped at 1h.

program_armed() is the single arming chokepoint: the settings-store
board_program.<key>.enabled override when present, else the legacy flag —
routed through BoardProgramEngine, RoadmapEngine.run_cycle, and XEngine's
spotlight gate, so the panel toggle can never be a silent no-op against a
legacy boot flag.

LEARN: board_program_cycles (migration 087) accrues per-item CEO decisions
(exact attribution by exploration_task_id where the caller holds it) and
feeds the last closed cycles back into both exploration prompts. The
strategy engine's idle signal now opens a roadmap cycle (enabled+dedup
respected) instead of only nudging.

Per-project scoping (migration 088, projects.board_programs, dual polarity):
plain keys opt a project INTO project-scoped programs; "!key" opts it OUT
of an org-scoped program's outputs (default eligible — parity). Enforced at
propose_roadmap (names the excluded project) and defensively at materialize;
validation rejects unknown keys and meaningless polarity both directions.

API: GET /api/board-programs + POST /api/board-programs/{key}/run-now
(CEO-gated); settings keys for both migrated programs.

* feat(panel): Board Programs card + per-project program controls

Business page gains a Programs tab: per-program rows (role, trigger, scope,
open-cycle badge), enabled switch on the settings-store key, Run now
(disabled while a cycle is open). The edit-project dialog gains the
program controls next to the CI-watch/video toggles: participates-in
checkboxes for project-scoped programs, excluded-from checkboxes for
org-scoped outputs.

* test(board): full-gate hermeticity — mypy casts + shared-DB purge fixtures

make quality runs one pytest process over all suites against the shared
persistent DB: integration collects before unit, so the board-programs API
test's committed run-now state (settings-store overrides, an open cycle row,
its board_roadmap task) poisoned 13 downstream unit tests that pass in
isolation. The polluter now purges its own committed state in fixture
teardown, and the four consumer files get an autouse per-test purge
(board_program.% settings keys, ledger rows, open exploration tasks) so
they are hermetic regardless of collection order. Also the four
cast("UUID", ...) sites the tests-scope mypy run requires.

* feat(panel): re-home per-project program controls onto the settings page

Wave C deleted the edit-project dialog these controls originally landed in;
they now live on the project settings page's budget/ops card next to the
CI-watch/video toggles — participates-in switches for project-scoped
programs, excluded-from switches for org-scoped outputs, dual-polarity
tooltips, order-independent dirty tracking. Nine makeProject test fixtures
gain the required board_programs field the rebase left behind.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-25 05:39:44 +02:00

347 lines
13 KiB
Python

"""roboco.services.gateway.content_actions.propose_roadmap — PO-gated themed
roadmap cycle authoring."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings as cfg
from roboco.foundation.policy.content import markers
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
class _FakeTask:
"""Minimal stand-in for the ORM TaskTable row — carries just what
``markers`` and ``propose_roadmap`` touch."""
def __init__(
self,
*,
assigned_to: Any,
orchestration_markers: dict[str, Any] | None = None,
) -> None:
self.id = uuid4()
self.assigned_to = assigned_to
self.orchestration_markers = orchestration_markers
def _actions(role: str, *, notification_delivery: Any = None) -> ContentActions:
task = MagicMock()
agent = MagicMock()
agent.role = role
task.agent_for = AsyncMock(return_value=agent)
task.session = MagicMock()
deps = ContentActionsDeps(
task=task,
git=MagicMock(),
a2a=MagicMock(),
journal=MagicMock(),
workspace=MagicMock(),
notifications=MagicMock(),
notification_delivery=notification_delivery,
)
return ContentActions(deps)
def _valid_item(idx: int) -> dict[str, Any]:
return {
"title": f"Item {idx}",
"description": f"A substantive description of item {idx}",
"acceptance_criteria": ["it does the thing", "it is tested"],
"project_slug": "backend-svc",
"team": "backend",
"priority": 2,
"rationale": f"Because it matters, reason {idx}",
}
def _valid_items(n: int) -> list[dict[str, Any]]:
return [_valid_item(i) for i in range(n)]
@pytest.fixture(autouse=True)
def _default_project_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 6b's exclusion check (``_reject_excluded_roadmap_project``) looks
up ``project_slug`` on every propose_roadmap call. Every test predating
that check builds its ``ContentActions`` with a bare ``MagicMock``
session, so default the lookup to "unresolvable" (None -> not rejected,
same as an unknown slug always behaved) instead of making every one of
them mock a project service it isn't testing. Tests exercising the
exclusion check override this target explicitly."""
stub = MagicMock()
stub.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr("roboco.services.project.get_project_service", lambda _s: stub)
@pytest.mark.asyncio
async def test_propose_roadmap_forbidden_for_non_po() -> None:
env = await _actions("head_marketing").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(3)
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_roadmap_forbidden_for_developer() -> None:
env = await _actions("developer").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(3)
)
assert env.error == "not_authorized"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_too_few_items(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 3)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(2)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_too_many_items(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 3)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(8)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_missing_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
bad = _valid_item(0)
del bad["rationale"]
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_unknown_team(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
bad = _valid_item(0)
bad["team"] = "board" # not a cell team
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_no_open_cycle_is_invalid_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(3)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_persists_cycle_onto_open_task(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
actions = _actions("product_owner")
actions.task.session.flush = AsyncMock()
items = _valid_items(3)
env = await actions.propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=items
)
assert env.error is None
assert env.status == "roadmap_proposed"
assert env.task_id == str(cycle_task.id)
payload = markers.get_roadmap_cycle(cycle_task)
assert payload is not None
assert payload["goal"] == "Close onboarding friction"
assert len(payload["items"]) == len(items)
assert all(it["status"] == "proposed" for it in payload["items"])
assert payload["items"][0]["id"] == "item-0"
actions.task.session.flush.assert_awaited_once()
@pytest.mark.asyncio
async def test_propose_roadmap_sends_telegram_push_per_item(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Roadmap items only become CEO-actionable once propose_roadmap lands
(the engine's exploration-task origination has nothing to review yet),
so the push DM fires once per item here, not from RoadmapEngine."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = AsyncMock()
actions = _actions("product_owner", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
items = _valid_items(3)
env = await actions.propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=items
)
assert env.error is None
assert notify.notify_ceo_of_queue_item.await_count == len(items)
id8 = str(cycle_task.id)[:8]
for i, call in enumerate(notify.notify_ceo_of_queue_item.await_args_list):
assert call.kwargs["kind"] == "roadmap"
assert call.kwargs["id8"] == id8
assert call.kwargs["extra"] == f"item-{i}"
assert call.kwargs["title"] == f"Item {i}"
@pytest.mark.asyncio
async def test_propose_roadmap_survives_telegram_push_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A Telegram send failure must never block propose_roadmap itself."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
notify = MagicMock()
notify.notify_ceo_of_queue_item = AsyncMock(side_effect=RuntimeError("boom"))
actions = _actions("product_owner", notification_delivery=notify)
actions.task.session.flush = AsyncMock()
env = await actions.propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=_valid_items(2)
)
assert env.error is None
assert env.status == "roadmap_proposed"
@pytest.mark.asyncio
async def test_propose_roadmap_ignores_cycle_assigned_to_another_agent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
other_agent = uuid4()
cycle_task = _FakeTask(assigned_to=other_agent)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("product_owner").propose_roadmap(
agent_id=uuid4(), cycle_goal="Close onboarding friction", items=_valid_items(3)
)
assert env.error == "invalid_state"
@pytest.mark.asyncio
async def test_propose_roadmap_rejects_item_targeting_excluded_project(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task 6b: an item targeting a project carrying '!roadmap' is refused
at propose time, naming the excluded project — before the PO even
finishes authoring the cycle, not just at the CEO's later approve."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
excluded_project = MagicMock(board_programs=["!roadmap"])
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=excluded_project)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_svc
)
bad = _valid_item(0)
bad["project_slug"] = "excluded-proj"
env = await _actions("product_owner").propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error == "invalid_state"
assert "excluded-proj" in (env.message or "")
@pytest.mark.asyncio
async def test_propose_roadmap_allows_unresolvable_project_slug_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An unknown project_slug is not this check's job — it surfaces
downstream at approve/materialize time, unchanged from before Task 6b."""
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
cycle_task = _FakeTask(assigned_to=agent_id)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
project_svc = MagicMock()
project_svc.get_by_slug = AsyncMock(return_value=None)
monkeypatch.setattr(
"roboco.services.project.get_project_service", lambda _s: project_svc
)
actions = _actions("product_owner")
actions.task.session.flush = AsyncMock()
bad = _valid_item(0)
bad["project_slug"] = "no-such-project"
env = await actions.propose_roadmap(
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
)
assert env.error is None
@pytest.mark.asyncio
async def test_propose_roadmap_ignores_already_authored_cycle(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
agent_id = uuid4()
authored_task = _FakeTask(
assigned_to=agent_id,
orchestration_markers={"roadmap_cycle": {"goal": "old", "items": []}},
)
task_svc = MagicMock()
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[authored_task])
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
env = await _actions("product_owner").propose_roadmap(
agent_id=agent_id, cycle_goal="A second cycle", items=_valid_items(3)
)
assert env.error == "invalid_state"