Files
roboco/roboco/services/roadmap_engine.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

116 lines
5.1 KiB
Python

"""RoadmapEngine — weekly board roadmap exploration, held for the CEO.
Mirrors the ReleaseManagerEngine "detect -> originate a CEO-gated artifact ->
hold" shape, but the artifact here is a themed cycle the Product Owner
AUTHORS rather than a report the engine assembles itself:
* **Default OFF.** Armed via ``roboco.services.board_programs.program_armed``
(the settings-store ``board_program.roadmap.enabled`` override, else the
legacy ``roadmap_engine_enabled`` flag) — off either way by default, so
the loop never runs and nothing is originated.
* **One open cycle at a time.** Dedup by ``source=board_roadmap`` non-terminal
tasks — a new cycle is never originated while one is still awaiting the
Product Owner's authoring or the CEO's per-item decisions.
* **The engine never authors content.** It opens ONE held, PENDING
exploration task assigned to the Product Owner (``Team.BOARD``,
``confirmed_by_human=False``); the existing board one-shot dispatch spawns
the PO, who explores and calls ``propose_roadmap`` exactly once. Approved
items materialize into BACKLOG only via the CEO's per-item approve
(``RoadmapService``) — this engine never starts anything.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from roboco.config import settings
from roboco.foundation import identity as _foundation
from roboco.models.base import Complexity, TaskNature, TaskStatus, TaskType, Team
from roboco.services.base import BaseService
from roboco.services.board_programs import program_armed
from roboco.services.project import get_project_service
from roboco.services.task import ROADMAP_SOURCE, TaskCreateRequest, get_task_service
if TYPE_CHECKING:
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from roboco.db.tables import ProjectTable, TaskTable
from roboco.services.task import TaskService
_EXPLORATION_TITLE = "Roadmap exploration cycle"
_EXPLORATION_DESCRIPTION = (
"Explore the company's projects, the charter, recent releases, and "
"metrics, then propose one themed cycle of roadmap item drafts via "
"propose_roadmap(). Each item is reviewed and approved/rejected "
"individually by the CEO in the roadmap queue; nothing here auto-starts "
"— an approved item lands in BACKLOG for normal PM activation."
)
class RoadmapEngine(BaseService):
"""Originate ONE held roadmap-exploration cycle for the Product Owner."""
service_name = "roadmap_engine"
async def run_cycle(self) -> TaskTable | None:
"""Originate one held exploration task, or None (no-op).
No-ops when the program isn't armed (``program_armed`` — settings-store
override, else the legacy flag), a cycle is already open, or the
RoboCo project isn't resolvable. Never authors content itself — the
Product Owner does, via ``propose_roadmap`` once spawned by the board
dispatcher.
"""
if not await program_armed(self.session, "roadmap"):
return None
task_svc = get_task_service(self.session)
if await task_svc.list_open_roadmap_cycles():
return None # one open cycle at a time
project = await self._roboco_project()
if project is None or project.id is None:
self.log.warning(
"roadmap-engine: RoboCo project not resolvable; skipping",
)
return None
return await self._originate(task_svc, cast("UUID", project.id))
async def _roboco_project(self) -> ProjectTable | None:
slug = (settings.self_heal_project_slug or "roboco-api").strip()
return await get_project_service(self.session).get_by_slug(slug)
async def _originate(self, task_svc: TaskService, project_id: UUID) -> TaskTable:
"""Open ONE PENDING, HELD exploration task assigned to the Product Owner."""
task = await task_svc.create(
TaskCreateRequest(
title=_EXPLORATION_TITLE,
description=_EXPLORATION_DESCRIPTION,
acceptance_criteria=[
f"propose_roadmap() is called once with a themed cycle of "
f"{settings.roadmap_min_items_per_cycle}-"
f"{settings.roadmap_max_items_per_cycle} item drafts",
],
team=Team.BOARD,
assigned_to=_foundation.AGENTS["product-owner"].uuid,
created_by=_foundation.AGENTS["system"].uuid,
task_type=TaskType.ADMINISTRATIVE,
nature=TaskNature.NON_TECHNICAL,
estimated_complexity=Complexity.LOW,
project_id=project_id,
status=TaskStatus.PENDING,
source=ROADMAP_SOURCE,
confirmed_by_human=False, # HELD; board-dispatched, not delivery
)
)
await self.session.flush()
self.log.info(
"roadmap exploration cycle opened (Product Owner)", task_id=str(task.id)
)
return task
def get_roadmap_engine(session: AsyncSession) -> RoadmapEngine:
"""Build a RoadmapEngine for ``session``."""
return RoadmapEngine(session)