Files
roboco/roboco/foundation/policy/board_programs.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

137 lines
5.0 KiB
Python

"""roboco/foundation/policy/board_programs.py
Board Program registry — the pure shape of "a board role periodically
originates held work". One entry per program; the engine/loop consult
this instead of growing bespoke per-engine loops. Foundation purity:
stdlib only, no IO.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from datetime import datetime
WEEK_SECONDS = 7 * 24 * 3600
class TriggerKind(StrEnum):
CRON = "cron" # due when interval elapsed since last opened cycle
METRIC = "metric" # due when the engine's metric predicate fires
EVENT = "event" # opened explicitly by an event hook, never by the loop
@dataclass(frozen=True)
class BoardProgram:
key: str
role: str # AgentRole value of the solo explorer
trigger: TriggerKind
source: str # tasks.source marker the dispatcher routes on
default_interval_seconds: int # cron cadence when no override is configured
max_items_per_cycle: int = 7
# "project" (reads one repo, e.g. a future bug hunt) needs a per-project
# opt-in to even run; "org" (reads the org's process/market, e.g. the
# roadmap cycle) runs org-wide by default and is only ever excluded per
# project as an OUTPUT target. See project_participates below.
scope: str = "org"
PROGRAMS: dict[str, BoardProgram] = {
p.key: p
for p in (
BoardProgram(
key="roadmap",
role="product_owner",
trigger=TriggerKind.CRON,
source="board_roadmap",
default_interval_seconds=WEEK_SECONDS,
),
BoardProgram(
key="x_feature",
role="head_marketing",
trigger=TriggerKind.CRON,
source="x_feature_exploration",
# Mirrors Settings.x_feature_spotlight_interval_seconds' own
# default (1 day) — see test_x_feature_default_interval_matches_
# settings_field_default, which guards the two from drifting
# apart again.
default_interval_seconds=86400,
),
)
}
def program_due(
program: BoardProgram,
*,
now: datetime,
last_opened_at: datetime | None,
interval_override: int | None,
) -> bool:
"""Cron-due check. METRIC/EVENT programs are opened by their own hooks."""
if program.trigger is not TriggerKind.CRON:
return False
if last_opened_at is None:
return True
interval = interval_override or program.default_interval_seconds
return (now - last_opened_at).total_seconds() >= interval
def project_participates(
program: BoardProgram, board_programs_field: list[str] | None
) -> bool:
"""Whether ``program`` runs/outputs against a project carrying this field.
Dual polarity (CEO, 2026-07-24): a ``scope="project"`` program (reads one
repo) is affirmative opt-in — True iff its key is listed; null/absent is
OUT. A ``scope="org"`` program (reads the org's process/market) is
default-eligible — True unless ``"!{key}"`` is listed; null/absent is IN,
preserving parity for programs migrated onto the registry.
"""
field = board_programs_field or []
if program.scope == "project":
return program.key in field
return f"!{program.key}" not in field
def validate_board_programs_field(
value: list[str] | None,
*,
programs: dict[str, BoardProgram] | None = None,
) -> list[str] | None:
"""Validate a ``projects.board_programs`` entry list.
Each entry is either a known program key (plain — the project-scoped
opt-in form) or an org-scoped key prefixed with ``!`` (the org-scoped
opt-out form). Raises ``ValueError`` on an unknown key, a ``!`` prefix on
a project-scoped key (meaningless — a project-scoped program's default is
already excluded, so there is nothing to opt out of), or a plain key on
an org-scoped key (meaningless the other way — an org-scoped program
already runs against every project by default, so there is nothing to
opt into; ``project_participates`` never consults a plain entry for it).
"""
if value is None:
return None
registry = PROGRAMS if programs is None else programs
for entry in value:
excluding = entry.startswith("!")
key = entry[1:] if excluding else entry
program = registry.get(key)
if program is None:
raise ValueError(f"unknown board program key {key!r}")
if excluding and program.scope != "org":
raise ValueError(
f"'!{key}' is meaningless on project-scoped program {key!r} — "
"its default is already excluded"
)
if not excluding and program.scope == "org":
raise ValueError(
f"{key!r} is meaningless on org-scoped program {key!r} — a "
"plain key would opt in, but org-scoped programs already run "
f"by default; use '!{key}' to exclude this project instead"
)
return list(value)