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>
This commit is contained in:
Renzo F
2026-07-25 05:39:44 +02:00
committed by GitHub
co-authored by Renn F
parent cbddfc7cb3
commit e77c3b7a63
52 changed files with 3662 additions and 213 deletions
@@ -0,0 +1,58 @@
"""Migration 087 tests — board_program_cycles table.
NOT a real alembic round-trip — the suite builds the test DB via
Base.metadata.create_all (see conftest); a real `alembic upgrade head` +
`downgrade -1` round trip against a scratch Postgres (:55432) was run
manually and confirmed clean (create + drop, no errors) as part of building
this migration. See `alembic/versions/087_board_program_cycles.py`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.db.tables import BoardProgramCycleTable
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
async def test_board_program_cycle_row_defaults(db_session: AsyncSession) -> None:
"""A freshly-inserted row gets zeroed counters and an empty decisions list."""
row = BoardProgramCycleTable(program_key="roadmap")
db_session.add(row)
await db_session.flush()
await db_session.refresh(row)
assert row.items_proposed == 0
assert row.items_approved == 0
assert row.items_rejected == 0
assert row.decisions == []
assert row.opened_at is not None
assert row.closed_at is None
assert row.exploration_task_id is None
@pytest.mark.asyncio
async def test_board_program_cycle_round_trips_decisions(
db_session: AsyncSession,
) -> None:
"""The decisions JSON column stores/returns a list of dicts byte-for-byte."""
decisions = [
{"item_ref": "item-1", "verdict": "approved", "reason": None},
{"item_ref": "item-2", "verdict": "rejected", "reason": "not now"},
]
row = BoardProgramCycleTable(
program_key="roadmap",
items_proposed=2,
items_approved=1,
items_rejected=1,
decisions=decisions,
)
db_session.add(row)
await db_session.flush()
await db_session.refresh(row)
assert row.decisions == decisions
@@ -0,0 +1,202 @@
"""Board Programs API route coverage — CEO-only list + run-now."""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.board_programs import router as board_programs_router
from roboco.config import settings as cfg
from roboco.db.tables import (
AgentTable,
BoardProgramCycleTable,
ProjectTable,
SystemSettingTable,
TaskTable,
)
from roboco.foundation import identity as _foundation
from roboco.models import AgentRole, AgentStatus, TaskStatus, Team
from roboco.models.permissions import AgentContext
from roboco.services.task import ROADMAP_SOURCE
from sqlalchemy import delete, update
CEO_UUID = _foundation.AGENTS["ceo"].uuid
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
PO_UUID = _foundation.AGENTS["product-owner"].uuid
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agents(session: AsyncSession) -> None:
for uuid, slug, role in (
(CEO_UUID, "ceo", AgentRole.CEO),
(SYSTEM_UUID, "system", AgentRole.SYSTEM),
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER),
):
if await session.get(AgentTable, uuid) is not None:
continue
session.add(
AgentTable(
id=uuid,
name=slug,
slug=slug,
role=role,
team=None,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
)
await session.flush()
async def _arm_roadmap(session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> None:
"""Arms roadmap two ways: the new per-program settings-store key (what
Task 7 makes writable, consulted by ``BoardProgramEngine.enabled``) AND
the legacy ``roadmap_engine_enabled`` config flag — ``RoadmapEngine.
run_cycle`` (the migrated originator itself, unchanged since before the
registry existed) independently checks the legacy flag and no-ops
without it, regardless of the registry-level settings-store override.
Also seeds the project ``RoadmapEngine._roboco_project`` resolves against
(a unique slug per call + a matching ``self_heal_project_slug`` override —
``db_session`` is a real, cross-test-persistent database within one
pytest run, so a fixed slug like "roboco-api" would collide the second
a sibling test also arms roadmap)."""
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
key = "board_program.roadmap.enabled"
existing = await session.get(SystemSettingTable, key)
if existing is None:
session.add(SystemSettingTable(key=key, value="true"))
else:
existing.value = "true"
slug = f"roboco-api-{uuid4().hex[:8]}"
monkeypatch.setattr(cfg, "self_heal_project_slug", slug)
session.add(
ProjectTable(
id=uuid4(),
name="RoboCo",
slug=slug,
git_url="https://example.com/roboco.git",
assigned_cell=Team.BACKEND,
created_by=SYSTEM_UUID,
)
)
await session.flush()
def _build_app(db_session: AsyncSession, role: AgentRole, agent_id: UUID) -> FastAPI:
app = FastAPI()
app.include_router(board_programs_router, prefix="/api/board-programs")
async def _override_db() -> AsyncIterator[AsyncSession]:
yield db_session
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=agent_id, role=role, team=None)
app.dependency_overrides[get_db] = _override_db
app.dependency_overrides[get_agent_context] = _override_agent
return app
@pytest_asyncio.fixture
async def ceo_client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
await _seed_agents(db_session)
app = _build_app(db_session, AgentRole.CEO, CEO_UUID)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
# run-now's route handler commits explicitly (write-route convention), so
# anything a test wrote through it (settings-store overrides, an opened
# ledger row, the board_roadmap task it originates) would otherwise
# outlive this test in the shared, cross-test-persistent DB and poison
# every later real-DB roadmap/board-program unit test (dedup checks,
# settings-store PK collisions, ledger scalar_one() lookups). Purge
# unconditionally — a no-op for the tests here that never wrote anything.
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 == ROADMAP_SOURCE,
TaskTable.status.notin_([TaskStatus.COMPLETED, TaskStatus.CANCELLED]),
)
.values(status=TaskStatus.CANCELLED)
)
await db_session.commit()
@pytest.mark.asyncio
async def test_list_returns_both_migrated_programs(ceo_client: AsyncClient) -> None:
resp = await ceo_client.get("/api/board-programs")
assert resp.status_code == HTTPStatus.OK
body = resp.json()
assert {p["key"] for p in body} == {"roadmap", "x_feature"}
roadmap = next(p for p in body if p["key"] == "roadmap")
assert roadmap["role"] == "product_owner"
assert roadmap["trigger"] == "cron"
assert roadmap["scope"] == "org"
assert roadmap["open_cycle"] is False
assert roadmap["last_opened_at"] is None
# Not asserted == [] — org-scoped "eligible" means every active project
# (default-eligible, opt-out only), and db_session is a real,
# cross-test-persistent database within one pytest run: sibling suites
# seed their own projects that legitimately show up here too.
assert isinstance(roadmap["opted_in_project_slugs"], list)
@pytest.mark.asyncio
async def test_run_now_opens_a_cycle_then_conflicts_on_retry(
db_session: AsyncSession,
ceo_client: AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One test, not two — ``RoadmapEngine.run_cycle``'s own dedup
(``list_open_roadmap_cycles``) is system-wide (any open ``board_roadmap``
task, not scoped to this test's project), and ``db_session`` is a real,
cross-test-persistent database within one pytest run: a leftover open
cycle from a sibling test would make the FIRST call here 409 too."""
await _arm_roadmap(db_session, monkeypatch)
first = await ceo_client.post("/api/board-programs/roadmap/run-now")
assert first.status_code == HTTPStatus.OK
body = first.json()
assert body["open_cycle"] is True
assert body["last_opened_at"] is not None
second = await ceo_client.post("/api/board-programs/roadmap/run-now")
assert second.status_code == HTTPStatus.CONFLICT
@pytest.mark.asyncio
async def test_run_now_unknown_key_is_404(ceo_client: AsyncClient) -> None:
resp = await ceo_client.post("/api/board-programs/not-a-real-program/run-now")
assert resp.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_non_ceo_is_forbidden(db_session: AsyncSession) -> None:
await _seed_agents(db_session)
app = _build_app(db_session, AgentRole.DEVELOPER, uuid4())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/board-programs")
assert resp.status_code == HTTPStatus.FORBIDDEN
app.dependency_overrides.clear()
@@ -76,15 +76,23 @@ async def board_gate_setup(
db_session: AsyncSession, _test_database_url: str
) -> AsyncIterator[dict]:
"""Seed agents + a board/coordination task and point the global DB holder
at the test database so the orchestrator's own session writes land here."""
db_session.add_all(
[
_agent(_SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
_agent(_CEO_UUID, "ceo", AgentRole.CEO, None),
_agent(_PO_UUID, _PO_SLUG, AgentRole.PRODUCT_OWNER, Team.BOARD),
_agent(_HOM_UUID, _HOM_SLUG, AgentRole.HEAD_MARKETING, Team.BOARD),
]
)
at the test database so the orchestrator's own session writes land here.
Existence-checked per row (mirrors ``test_roadmap_routes.py``'s
``_seed_ceo`` / ``test_feature_spotlight.py``'s
``_seed_system_and_secretary``): ``db_session`` is a real,
cross-test-persistent database within one pytest run, and these are the
same fixed foundation UUIDs another integration suite may have already
committed (a write-route test that explicitly commits, e.g. the Board
Programs API's run-now) — an unconditional insert would collide."""
for uuid, slug, role, team in (
(_SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
(_CEO_UUID, "ceo", AgentRole.CEO, None),
(_PO_UUID, _PO_SLUG, AgentRole.PRODUCT_OWNER, Team.BOARD),
(_HOM_UUID, _HOM_SLUG, AgentRole.HEAD_MARKETING, Team.BOARD),
):
if await db_session.get(AgentTable, UUID(uuid)) is None:
db_session.add(_agent(uuid, slug, role, team))
await db_session.flush()
# A board/coordination task: project_id NULL (git-exempt), team=board,