mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* 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>
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""RoadmapService coverage: per-item approve materializes a BACKLOG task
|
|
(idempotent), reject records a reason (idempotent), and the exploration task
|
|
completes once every item is terminal.
|
|
|
|
Mirrors the X-post-service / release-proposal-service tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import TYPE_CHECKING, cast
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from roboco.db.tables import (
|
|
AgentTable,
|
|
AuditLogTable,
|
|
BoardProgramCycleTable,
|
|
ProjectTable,
|
|
SystemSettingTable,
|
|
TaskTable,
|
|
)
|
|
from roboco.foundation import identity as _foundation
|
|
from roboco.foundation.policy.content import markers
|
|
from roboco.models.base import (
|
|
AgentRole,
|
|
AgentStatus,
|
|
Complexity,
|
|
Team,
|
|
)
|
|
from roboco.models.base import TaskNature as TN
|
|
from roboco.models.base import TaskStatus as TS
|
|
from roboco.models.base import TaskType as TT
|
|
from roboco.services import board_programs as bp_module
|
|
from roboco.services.roadmap_service import RoadmapService, get_roadmap_service
|
|
from roboco.services.task import (
|
|
ROADMAP_ITEM_SOURCE,
|
|
ROADMAP_SOURCE,
|
|
X_FEATURE_EXPLORATION_SOURCE,
|
|
)
|
|
from sqlalchemy import delete, select, update
|
|
|
|
if TYPE_CHECKING:
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
|
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
|
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
|
ONE = 1
|
|
TWO = 2
|
|
|
|
|
|
@pytest_asyncio.fixture(autouse=True)
|
|
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
|
|
"""See ``test_board_program_engine.py``'s identical fixture: Board
|
|
Program settings-store rows / ledger rows / open exploration tasks are
|
|
shared, cross-test-persistent DB state that a sibling suite (this
|
|
module's own ``_seed_cycle_ledger_row`` rows, or the write-route
|
|
``test_board_programs_api.py`` run-now test) can leave behind — this
|
|
module's ``scalar_one()`` ledger lookups need exactly one row. Purge
|
|
before every test in this file."""
|
|
await db_session.execute(
|
|
delete(SystemSettingTable).where(SystemSettingTable.key.like("board_program.%"))
|
|
)
|
|
await db_session.execute(delete(BoardProgramCycleTable))
|
|
await db_session.execute(
|
|
update(TaskTable)
|
|
.where(
|
|
TaskTable.source.in_([ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE]),
|
|
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
|
|
)
|
|
.values(status=TS.CANCELLED)
|
|
)
|
|
await db_session.commit()
|
|
|
|
|
|
def _item(idx: int, *, status: str = "proposed", project_slug: str) -> dict:
|
|
return {
|
|
"id": f"item-{idx}",
|
|
"title": f"Item {idx}",
|
|
"description": f"Description for item {idx} that is long enough",
|
|
"acceptance_criteria": [f"criterion {idx}a", f"criterion {idx}b"],
|
|
"project_slug": project_slug,
|
|
"team": "backend",
|
|
"priority": 2,
|
|
"rationale": f"Rationale for item {idx}",
|
|
"status": status,
|
|
"reject_reason": None,
|
|
"materialized_task_id": None,
|
|
}
|
|
|
|
|
|
async def _seed_agents(session: AsyncSession) -> None:
|
|
for uuid, slug, role, team in (
|
|
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
|
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
|
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
|
):
|
|
if await session.get(AgentTable, uuid) is None:
|
|
session.add(
|
|
AgentTable(
|
|
id=uuid,
|
|
name=slug,
|
|
slug=slug,
|
|
role=role,
|
|
team=team,
|
|
status=AgentStatus.ACTIVE,
|
|
model_config={},
|
|
system_prompt="x",
|
|
capabilities=[],
|
|
permissions={},
|
|
metrics={},
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
async def _seed_project(session: AsyncSession, slug: str) -> ProjectTable:
|
|
await _seed_agents(session)
|
|
project = ProjectTable(
|
|
id=uuid4(),
|
|
name=slug,
|
|
slug=slug,
|
|
git_url=f"https://example.com/{slug}.git",
|
|
assigned_cell=Team.BACKEND,
|
|
created_by=SYSTEM_UUID,
|
|
)
|
|
session.add(project)
|
|
await session.flush()
|
|
return project
|
|
|
|
|
|
async def _seed_cycle(
|
|
session: AsyncSession, *, items: list[dict] | None = None, project_slug: str
|
|
) -> TaskTable:
|
|
await _seed_agents(session)
|
|
task = TaskTable(
|
|
id=uuid4(),
|
|
title="Roadmap exploration cycle",
|
|
description="Explore and propose a themed cycle of roadmap items.",
|
|
acceptance_criteria=["propose_roadmap() called once"],
|
|
status=TS.PENDING,
|
|
priority=2,
|
|
task_type=TT.ADMINISTRATIVE,
|
|
nature=TN.NON_TECHNICAL,
|
|
estimated_complexity=Complexity.LOW,
|
|
created_by=SYSTEM_UUID,
|
|
assigned_to=PO_UUID,
|
|
team=Team.BOARD,
|
|
source=ROADMAP_SOURCE,
|
|
confirmed_by_human=False,
|
|
)
|
|
session.add(task)
|
|
await session.flush()
|
|
items = items or [
|
|
_item(0, project_slug=project_slug),
|
|
_item(1, project_slug=project_slug),
|
|
]
|
|
markers.set_roadmap_cycle(
|
|
task, {"goal": "Close onboarding friction", "items": items}
|
|
)
|
|
await session.flush()
|
|
return task
|
|
|
|
|
|
def _svc(session: AsyncSession) -> RoadmapService:
|
|
return get_roadmap_service(session)
|
|
|
|
|
|
def _id(task: TaskTable) -> UUID:
|
|
return cast("UUID", task.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
result = await _svc(db_session).approve_item(
|
|
_id(task), "item-0", created_by=CEO_UUID
|
|
)
|
|
assert result is not None
|
|
assert result.status == "approved"
|
|
assert result.materialized_task_id is not None
|
|
|
|
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
|
assert materialized is not None
|
|
assert materialized.status == TS.BACKLOG
|
|
assert materialized.source == ROADMAP_ITEM_SOURCE
|
|
assert materialized.team == Team.BACKEND
|
|
|
|
await db_session.refresh(task)
|
|
payload = markers.get_roadmap_cycle(task)
|
|
assert payload is not None
|
|
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
|
|
assert item0["status"] == "approved"
|
|
assert item0["materialized_task_id"] == result.materialized_task_id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_is_idempotent(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
first = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
second = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
assert first is not None
|
|
assert second is not None
|
|
assert second.status == "already_approved"
|
|
assert second.materialized_task_id == first.materialized_task_id
|
|
|
|
# No duplicate BACKLOG task was created for the second approve — scoped to
|
|
# this item's own (test-unique) title since the source column is shared
|
|
# across the whole (persistent, cross-test) database.
|
|
result = await db_session.execute(
|
|
select(TaskTable).where(
|
|
TaskTable.source == ROADMAP_ITEM_SOURCE, TaskTable.title == "Item 0"
|
|
)
|
|
)
|
|
assert len(result.scalars().all()) == ONE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_records_reason(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
result = await _svc(db_session).reject_item(
|
|
_id(task), "item-0", "not a priority this quarter"
|
|
)
|
|
assert result is not None
|
|
assert result.status == "rejected"
|
|
|
|
await db_session.refresh(task)
|
|
payload = markers.get_roadmap_cycle(task)
|
|
assert payload is not None
|
|
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
|
|
assert item0["status"] == "rejected"
|
|
assert item0["reject_reason"] == "not a priority this quarter"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_is_idempotent(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
await svc.reject_item(_id(task), "item-0", "reason one")
|
|
second = await svc.reject_item(_id(task), "item-0", "reason two")
|
|
assert second is not None
|
|
assert second.status == "already_rejected"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cannot_reject_an_approved_item(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
result = await svc.reject_item(_id(task), "item-0", "changed my mind")
|
|
assert result is not None
|
|
assert result.status == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cannot_approve_a_rejected_item(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
await svc.reject_item(_id(task), "item-0", "not now")
|
|
result = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
assert result is not None
|
|
assert result.status == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_items_terminal_completes_exploration_task(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
assert task.status == TS.PENDING # one item still proposed
|
|
await svc.reject_item(_id(task), "item-1", "not now")
|
|
assert task.status == TS.COMPLETED # both items terminal
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_unknown_project_slug_is_invalid_state(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
task = await _seed_cycle(db_session, project_slug="no-such-project")
|
|
result = await _svc(db_session).approve_item(
|
|
_id(task), "item-0", created_by=CEO_UUID
|
|
)
|
|
assert result is not None
|
|
assert result.status == "invalid_state"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_excluded_project_is_invalid_state(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
"""Task 6b: a project carrying '!roadmap' refuses materialize-side, even
|
|
if propose_roadmap's own point-in-time check somehow let the item
|
|
through (e.g. the project was excluded AFTER the PO proposed it)."""
|
|
project = await _seed_project(db_session, "excluded-svc")
|
|
project.board_programs = ["!roadmap"]
|
|
await db_session.flush()
|
|
task = await _seed_cycle(db_session, project_slug="excluded-svc")
|
|
result = await _svc(db_session).approve_item(
|
|
_id(task), "item-0", created_by=CEO_UUID
|
|
)
|
|
assert result is not None
|
|
assert result.status == "invalid_state"
|
|
assert "excluded" in result.detail
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
|
|
result = await _svc(db_session).approve_item(uuid4(), "item-0", created_by=CEO_UUID)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_item_id_returns_none(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
result = await _svc(db_session).approve_item(
|
|
_id(task), "item-999", created_by=CEO_UUID
|
|
)
|
|
assert result is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_open_cycles_excludes_completed(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(
|
|
db_session,
|
|
items=[_item(0, project_slug="backend-svc")],
|
|
project_slug="backend-svc",
|
|
)
|
|
svc = _svc(db_session)
|
|
open_before = await svc.list_open_cycles()
|
|
assert task.id in {t.id for t in open_before}
|
|
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
open_after = await svc.list_open_cycles()
|
|
assert task.id not in {t.id for t in open_after}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_maybe_complete_cycle_emits_audit(db_session: AsyncSession) -> None:
|
|
# All items terminal -> task completed + a task.completed audit row.
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
svc = _svc(db_session)
|
|
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
assert task.status == TS.PENDING # one item still proposed
|
|
await svc.reject_item(_id(task), "item-1", "not now")
|
|
assert cast("TS", task.status) == TS.COMPLETED # both items terminal
|
|
|
|
rows = (
|
|
(
|
|
await db_session.execute(
|
|
select(AuditLogTable).where(AuditLogTable.target_id == task.id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
audit = [
|
|
r
|
|
for r in rows
|
|
if r.event_type == "task.completed"
|
|
or str(r.details.get("to_status", "")).lower() == "completed"
|
|
]
|
|
assert audit, (
|
|
"expected a task.completed audit row for the PENDING -> COMPLETED transition"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# LEARN wiring (Task 5): approve/reject best-effort record onto the open
|
|
# board_program_cycles row for "roadmap" — see test_board_program_engine.py
|
|
# for record_decision's own counter/close-on-terminal coverage.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
|
|
session.add(
|
|
BoardProgramCycleTable(
|
|
program_key="roadmap",
|
|
exploration_task_id=task.id,
|
|
opened_at=datetime.now(UTC),
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_records_learn_decision(db_session: AsyncSession) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
await _seed_cycle_ledger_row(db_session, task)
|
|
await _svc(db_session).approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
|
|
|
row = (
|
|
await db_session.execute(
|
|
select(BoardProgramCycleTable).where(
|
|
BoardProgramCycleTable.program_key == "roadmap"
|
|
)
|
|
)
|
|
).scalar_one()
|
|
assert row.items_approved == ONE
|
|
assert {
|
|
"item_ref": "item-0",
|
|
"verdict": "approved",
|
|
"reason": None,
|
|
} in row.decisions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_records_learn_decision_with_reason(
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
await _seed_cycle_ledger_row(db_session, task)
|
|
await _svc(db_session).reject_item(_id(task), "item-0", "not a priority")
|
|
|
|
row = (
|
|
await db_session.execute(
|
|
select(BoardProgramCycleTable).where(
|
|
BoardProgramCycleTable.program_key == "roadmap"
|
|
)
|
|
)
|
|
).scalar_one()
|
|
assert row.items_rejected == ONE
|
|
assert {
|
|
"item_ref": "item-0",
|
|
"verdict": "rejected",
|
|
"reason": "not a priority",
|
|
} in row.decisions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_survives_learn_recording_failure(
|
|
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A record_decision blow-up must never break the CEO's approve."""
|
|
await _seed_project(db_session, "backend-svc")
|
|
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
|
await _seed_cycle_ledger_row(db_session, task)
|
|
|
|
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
|
|
raise RuntimeError("learn boom")
|
|
|
|
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
|
|
result = await _svc(db_session).approve_item(
|
|
_id(task), "item-0", created_by=CEO_UUID
|
|
)
|
|
assert result is not None
|
|
assert result.status == "approved"
|