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
+11 -1
View File
@@ -13,13 +13,14 @@ import { GoalsTab } from "@/components/business/goals-tab";
import { CompanyScorecardCard } from "@/components/business/company-scorecard-card";
import { SecretaryTab } from "@/components/business/secretary-tab";
import { PitchesTab } from "@/components/business/pitches-tab";
import { BoardProgramsCard } from "@/components/business/board-programs-card";
// ---------------------------------------------------------------------------
// Valid tab values
// ---------------------------------------------------------------------------
interface TabDef {
value: "goals" | "scorecard" | "secretary" | "pitches";
value: "goals" | "scorecard" | "secretary" | "pitches" | "programs";
label: string;
hint: string;
}
@@ -45,6 +46,11 @@ const TAB_DEFS: TabDef[] = [
label: "Pitches",
hint: "Board-authored product pitches awaiting your decision",
},
{
value: "programs",
label: "Programs",
hint: "Board roles' periodic exploration cycles — enable, monitor, and run off-schedule",
},
];
const TAB_VALUES = TAB_DEFS.map((t) => t.value);
@@ -118,6 +124,10 @@ function BusinessPageContent() {
<TabsContent value="pitches" className="mt-4">
<PitchesTab />
</TabsContent>
<TabsContent value="programs" className="mt-4">
<BoardProgramsCard />
</TabsContent>
</Tabs>
</div>
);
@@ -98,6 +98,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { BoardProgram } from "@/lib/api/board-programs";
const { resolveListRef } = vi.hoisted(() => ({
resolveListRef: { current: null as null | ((v: unknown) => void) },
}));
function buildProgram(overrides: Partial<BoardProgram> = {}): BoardProgram {
return {
key: "roadmap",
role: "product_owner",
trigger: "cron",
scope: "org",
enabled: true,
opted_in_project_slugs: [],
last_opened_at: null,
open_cycle: false,
last_cycle_summary: null,
...overrides,
};
}
const { list, runNow } = vi.hoisted(() => ({
list: vi.fn(
() =>
new Promise((r) => {
resolveListRef.current = r as (v: unknown) => void;
}),
),
runNow: vi.fn(async () => buildProgram({ open_cycle: true })),
}));
vi.mock("@/lib/api/board-programs", () => ({
boardProgramsApi: { list, runNow },
}));
const { setFeatureFlag } = vi.hoisted(() => ({
setFeatureFlag: vi.fn(async () => undefined),
}));
vi.mock("@/lib/api/settings", () => ({
settingsApi: { setFeatureFlag },
}));
const { toast } = vi.hoisted(() => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("sonner", () => ({ toast }));
import { BoardProgramsCard } from "../board-programs-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("BoardProgramsCard", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveListRef.current = null;
});
it("shows a loading state before the list resolves", () => {
render(withQueryClient(<BoardProgramsCard />));
expect(
document.querySelectorAll('[data-slot="skeleton"]').length,
).toBeGreaterThan(0);
expect(screen.queryByText("roadmap")).not.toBeInTheDocument();
});
it("shows an empty state when no programs are registered", async () => {
render(withQueryClient(<BoardProgramsCard />));
resolveListRef.current?.([]);
expect(
await screen.findByText("No Board Programs registered yet."),
).toBeInTheDocument();
});
it("renders each program's key, role, trigger, and scope", async () => {
render(withQueryClient(<BoardProgramsCard />));
resolveListRef.current?.([
buildProgram({ key: "roadmap", role: "product_owner" }),
buildProgram({
key: "x_feature",
role: "head_marketing",
trigger: "cron",
}),
]);
expect(await screen.findByText("roadmap")).toBeInTheDocument();
expect(screen.getByText("x_feature")).toBeInTheDocument();
expect(screen.getByText("product_owner")).toBeInTheDocument();
expect(screen.getByText("head_marketing")).toBeInTheDocument();
});
it("shows a cycle-open badge and disables Run now while a cycle is open", async () => {
render(withQueryClient(<BoardProgramsCard />));
resolveListRef.current?.([buildProgram({ open_cycle: true })]);
expect(await screen.findByText("cycle open")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Run now/i })).toBeDisabled();
});
it("calls run-now and shows a success toast", async () => {
render(withQueryClient(<BoardProgramsCard />));
resolveListRef.current?.([buildProgram({ open_cycle: false })]);
const runButton = await screen.findByRole("button", { name: /Run now/i });
expect(runButton).not.toBeDisabled();
fireEvent.click(runButton);
await waitFor(() => expect(runNow).toHaveBeenCalledWith("roadmap"));
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith("roadmap cycle opened"),
);
});
it("toggles the enabled switch via the settings mutation", async () => {
render(withQueryClient(<BoardProgramsCard />));
resolveListRef.current?.([buildProgram({ enabled: true })]);
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() =>
expect(setFeatureFlag).toHaveBeenCalledWith(
"board_program.roadmap.enabled",
false,
),
);
});
});
@@ -0,0 +1,179 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Play } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { getErrorMessage } from "@/lib/api/client";
import { boardProgramsApi, type BoardProgram } from "@/lib/api/board-programs";
import { settingsApi } from "@/lib/api/settings";
const TRIGGER_HINTS: Record<string, string> = {
cron: "Runs on a fixed cadence.",
metric: "Runs when a monitored metric crosses a threshold.",
event: "Opened only by an explicit event hook, never by the loop.",
};
function ProgramRowSkeleton() {
return (
<div className="rounded-lg border p-4 space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-64" />
</div>
);
}
function ProgramRow({ program }: { program: BoardProgram }) {
const qc = useQueryClient();
const toggleMutation = useMutation({
mutationFn: (enabled: boolean) =>
settingsApi.setFeatureFlag(
`board_program.${program.key}.enabled`,
enabled,
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["board-programs"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
const runNowMutation = useMutation({
mutationFn: () => boardProgramsApi.runNow(program.key),
onSuccess: () => {
toast.success(`${program.key} cycle opened`);
void qc.invalidateQueries({ queryKey: ["board-programs"] });
},
onError: (e) => toast.error(getErrorMessage(e)),
});
return (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{program.key}</span>
<HelpTip label={`Explored by the ${program.role} role.`}>
<Badge variant="outline">{program.role}</Badge>
</HelpTip>
<HelpTip label={TRIGGER_HINTS[program.trigger] ?? ""}>
<Badge variant="secondary">{program.trigger}</Badge>
</HelpTip>
<HelpTip
label={
program.scope === "project"
? "Reads one repo — only opted-in projects feed its cycles."
: "Reads the org's process/market — runs org-wide by default."
}
>
<Badge variant="outline">{program.scope}</Badge>
</HelpTip>
{program.open_cycle && (
<HelpTip label="A cycle is already open — Run now is disabled until it closes.">
<Badge>cycle open</Badge>
</HelpTip>
)}
</div>
<p className="text-sm text-muted-foreground mt-1">
{program.last_opened_at
? `Last run: ${new Date(program.last_opened_at).toLocaleString()}`
: "Never run"}
{program.last_cycle_summary
? `${program.last_cycle_summary}`
: ""}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2">
<HelpTip
label={`Toggle the ${program.key} program on/off. Persists immediately; the background loop picks it up on its next tick.`}
>
<Label
htmlFor={`board-program-${program.key}`}
className="text-xs text-muted-foreground"
>
Enabled
</Label>
</HelpTip>
<Switch
id={`board-program-${program.key}`}
checked={program.enabled}
disabled={toggleMutation.isPending}
onCheckedChange={(checked) => toggleMutation.mutate(checked)}
/>
</div>
<HelpTip
label={
program.open_cycle
? "A cycle is already open for this program."
: "Open a cycle off-schedule, ignoring the cron cadence."
}
>
<span className="inline-block">
<Button
size="sm"
variant="outline"
disabled={program.open_cycle || runNowMutation.isPending}
onClick={() => runNowMutation.mutate()}
>
<Play className="mr-1 h-4 w-4" /> Run now
</Button>
</span>
</HelpTip>
</div>
</div>
</div>
);
}
export function BoardProgramsCard() {
const {
data: programs = [],
isLoading,
isError,
refetch,
} = useQuery({
queryKey: ["board-programs"],
queryFn: () => boardProgramsApi.list(),
refetchInterval: 30000,
});
return (
<Card>
<CardHeader>
<CardTitle>Board Programs</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<ProgramRowSkeleton />
<ProgramRowSkeleton />
</div>
) : isError ? (
<OfflineState
title="Failed to load Board Programs"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : programs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No Board Programs registered yet.
</p>
) : (
<div className="space-y-3">
{programs.map((p) => (
<ProgramRow key={p.key} program={p} />
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -103,6 +103,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { Team } from "@/types";
import type { Project } from "@/types";
import type { BoardProgram } from "@/lib/api/board-programs";
const { useUpdateProject, mutateAsync } = vi.hoisted(() => ({
useUpdateProject: vi.fn(),
@@ -10,6 +13,16 @@ const { useUpdateProject, mutateAsync } = vi.hoisted(() => ({
vi.mock("@/hooks/use-projects", () => ({ useUpdateProject }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const { listBoardPrograms } = vi.hoisted(() => ({
// Empty by default so the pre-existing suites below (none of which test
// this section) render with no participate/exclude checkboxes; the Board
// Programs describe block overrides this per test.
listBoardPrograms: vi.fn(async (): Promise<BoardProgram[]> => []),
}));
vi.mock("@/lib/api/board-programs", () => ({
boardProgramsApi: { list: listBoardPrograms },
}));
if (typeof window !== "undefined" && !window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
@@ -50,6 +63,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -60,21 +74,46 @@ function makeProject(overrides: Partial<Project> = {}): Project {
};
}
function buildProgram(overrides: Partial<BoardProgram> = {}): BoardProgram {
return {
key: "pest_control",
role: "product_owner",
trigger: "cron",
scope: "project",
enabled: true,
opted_in_project_slugs: [],
last_opened_at: null,
open_cycle: false,
last_cycle_summary: null,
...overrides,
};
}
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
function renderCard(project: Project) {
return render(withQueryClient(<BudgetOpsCard project={project} />));
}
describe("BudgetOpsCard", () => {
beforeEach(() => {
vi.clearAllMocks();
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
listBoardPrograms.mockResolvedValue([]);
});
it("pre-fills the stored monthly budget and shows spend against it", () => {
render(
<BudgetOpsCard
project={makeProject({
monthly_budget_usd: 100,
monthly_spend_usd: 42.5,
})}
/>,
renderCard(
makeProject({
monthly_budget_usd: 100,
monthly_spend_usd: 42.5,
}),
);
expect(screen.getByLabelText(/Monthly Budget/i)).toHaveValue(100);
expect(screen.getByTestId("project-spend").textContent).toBe(
@@ -83,7 +122,7 @@ describe("BudgetOpsCard", () => {
});
it("Save is disabled until a field changes", () => {
render(<BudgetOpsCard project={makeProject()} />);
renderCard(makeProject());
const save = screen.getByRole("button", { name: /^Save$/i });
expect(save).toBeDisabled();
@@ -92,7 +131,7 @@ describe("BudgetOpsCard", () => {
});
it("rejects a 0 or negative budget without saving", () => {
render(<BudgetOpsCard project={makeProject()} />);
renderCard(makeProject());
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "0" },
});
@@ -105,7 +144,7 @@ describe("BudgetOpsCard", () => {
});
it("saves an explicit null when the budget is cleared", async () => {
render(<BudgetOpsCard project={makeProject({ monthly_budget_usd: 42 })} />);
renderCard(makeProject({ monthly_budget_usd: 42 }));
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "" },
});
@@ -121,7 +160,7 @@ describe("BudgetOpsCard", () => {
});
it("saves the CI-watch, video-engine, and dep-update fields together", async () => {
render(<BudgetOpsCard project={makeProject()} />);
renderCard(makeProject());
fireEvent.click(screen.getByRole("switch", { name: /CI-watch/i }));
fireEvent.change(screen.getByLabelText(/CI-watch Workflow/i), {
target: { value: "ci.yml" },
@@ -149,6 +188,89 @@ describe("BudgetOpsCard", () => {
video_engine_enabled: true,
dep_update_command: "uv lock --upgrade",
dep_update_paths: ["uv.lock", "pnpm-lock.yaml"],
board_programs: [],
});
});
});
describe("BudgetOpsCard — Board Programs", () => {
beforeEach(() => {
vi.clearAllMocks();
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
listBoardPrograms.mockResolvedValue([
buildProgram({ key: "pest_control", scope: "project" }),
buildProgram({ key: "roadmap", scope: "org", role: "product_owner" }),
]);
});
it("renders a project-scoped program as a participates-in checkbox", async () => {
renderCard(makeProject({ board_programs: null }));
expect(
await screen.findByText("Board Programs — participates in"),
).toBeInTheDocument();
expect(screen.getByText("pest_control")).toBeInTheDocument();
expect(
screen.getByRole("switch", { name: "pest_control" }),
).not.toBeChecked();
});
it("renders an org-scoped program as an excluded-from checkbox", async () => {
renderCard(makeProject({ board_programs: null }));
expect(
await screen.findByText("Board Programs — excluded from"),
).toBeInTheDocument();
expect(screen.getByText("roadmap")).toBeInTheDocument();
});
it("pre-checks a project-scoped checkbox already in the stored list", async () => {
renderCard(makeProject({ board_programs: ["pest_control"] }));
expect(
await screen.findByRole("switch", { name: "pest_control" }),
).toBeChecked();
});
it("pre-checks an org-scoped exclusion checkbox already in the stored list", async () => {
renderCard(makeProject({ board_programs: ["!roadmap"] }));
expect(
await screen.findByText("Board Programs — excluded from"),
).toBeInTheDocument();
expect(screen.getByRole("switch", { name: "roadmap" })).toBeChecked();
});
it("toggling participates-in and excluded-from checkboxes submits both entries", async () => {
renderCard(makeProject({ board_programs: null }));
fireEvent.click(
await screen.findByRole("switch", { name: "pest_control" }),
);
fireEvent.click(screen.getByRole("switch", { name: "roadmap" }));
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { board_programs?: string[] };
};
expect(new Set(call.updates.board_programs)).toEqual(
new Set(["pest_control", "!roadmap"]),
);
});
it("saving an unrelated field round-trips untouched Board Programs unchanged", async () => {
renderCard(makeProject({ board_programs: ["!roadmap"] }));
await screen.findByText("Board Programs — excluded from");
fireEvent.click(screen.getByRole("switch", { name: /CI-watch/i }));
fireEvent.click(screen.getByRole("button", { name: /^Save$/i }));
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { board_programs?: string[] };
};
expect(call.updates.board_programs).toEqual(["!roadmap"]);
});
});
@@ -41,6 +41,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -50,6 +50,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -51,6 +51,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -109,6 +109,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -88,6 +88,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -49,6 +49,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useUpdateProject } from "@/hooks/use-projects";
import {
Card,
@@ -16,8 +17,20 @@ import { HelpTip } from "@/components/ui/help-tip";
import { Wallet } from "lucide-react";
import { toast } from "sonner";
import type { Project, ProjectUpdate } from "@/types";
import { boardProgramsApi } from "@/lib/api/board-programs";
import { SaveBar } from "./save-bar";
// Set equality, order-independent — the two checkbox groups below toggle
// entries in and out of insertion order, so a plain array/index compare
// (the protected_branches idiom) would false-positive dirty on a
// checked-then-unchecked round trip that lands back at the same members
// in a different order.
function sameStringSet(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
export function BudgetOpsCard({ project }: { project: Project }) {
const updateProject = useUpdateProject();
@@ -42,6 +55,31 @@ export function BudgetOpsCard({ project }: { project: Project }) {
(project.dep_update_paths || []).join(", "),
);
// Board Program per-project scoping: a project-scoped program's plain key
// opts this project INTO its cycles; an org-scoped program's '!'-prefixed
// key opts this project OUT of its output. Same underlying set for both —
// the two checkbox groups below just read/write different string forms.
const { data: boardPrograms = [] } = useQuery({
queryKey: ["board-programs"],
queryFn: () => boardProgramsApi.list(),
});
const projectScopedPrograms = boardPrograms.filter(
(p) => p.scope === "project",
);
const orgScopedPrograms = boardPrograms.filter((p) => p.scope === "org");
const originalBoardPrograms = new Set(project.board_programs ?? []);
const [boardProgramsSet, setBoardProgramsSet] = useState<Set<string>>(
new Set(originalBoardPrograms),
);
const toggleBoardProgramEntry = (entry: string, checked: boolean) => {
setBoardProgramsSet((prev) => {
const next = new Set(prev);
if (checked) next.add(entry);
else next.delete(entry);
return next;
});
};
const dirty =
monthlyBudgetUsd !==
(project.monthly_budget_usd != null
@@ -51,7 +89,8 @@ export function BudgetOpsCard({ project }: { project: Project }) {
ciWatchWorkflow !== (project.ci_watch_workflow || "") ||
videoEngineEnabled !== project.video_engine_enabled ||
depUpdateCommand !== (project.dep_update_command || "") ||
depUpdatePaths !== (project.dep_update_paths || []).join(", ");
depUpdatePaths !== (project.dep_update_paths || []).join(", ") ||
!sameStringSet(boardProgramsSet, originalBoardPrograms);
const handleSave = async () => {
const trimmedBudget = monthlyBudgetUsd.trim();
@@ -77,6 +116,10 @@ export function BudgetOpsCard({ project }: { project: Project }) {
.map((p) => p.trim())
.filter(Boolean)
: undefined,
// Always sent (even empty) — this card owns the full state of this
// field, same as every other field above; an empty array is the
// "no participation, no exclusion" value, equivalent to null on read.
board_programs: [...boardProgramsSet],
};
try {
@@ -175,6 +218,67 @@ export function BudgetOpsCard({ project }: { project: Project }) {
/>
</div>
{projectScopedPrograms.length > 0 && (
<div className="grid gap-2">
<HelpTip label="Project-scoped Board Programs read this repo directly (e.g. a bug hunt) — check to opt this project into a program's cycles. Also needs that program armed on the Business → Programs page.">
<Label>Board Programs participates in</Label>
</HelpTip>
{projectScopedPrograms.map((p) => (
<div key={p.key} className="flex items-center justify-between">
<HelpTip
label={`Explored by the ${p.role} role, ${p.trigger} trigger.`}
>
<Label
htmlFor={`board_program_${p.key}`}
className="text-sm font-normal"
>
{p.key}
</Label>
</HelpTip>
<Switch
id={`board_program_${p.key}`}
checked={boardProgramsSet.has(p.key)}
onCheckedChange={(checked) =>
toggleBoardProgramEntry(p.key, checked)
}
/>
</div>
))}
</div>
)}
{orgScopedPrograms.length > 0 && (
<div className="grid gap-2">
<HelpTip label="Org-scoped Board Programs (e.g. the roadmap cycle) propose into every project by default — check to exclude this specific project as an output target. The program itself still runs org-wide either way.">
<Label>Board Programs excluded from</Label>
</HelpTip>
{orgScopedPrograms.map((p) => {
const flag = `!${p.key}`;
return (
<div key={p.key} className="flex items-center justify-between">
<HelpTip
label={`Excludes this project as a ${p.key} output target only; ${p.key} keeps running org-wide.`}
>
<Label
htmlFor={`board_program_excl_${p.key}`}
className="text-sm font-normal"
>
{p.key}
</Label>
</HelpTip>
<Switch
id={`board_program_excl_${p.key}`}
checked={boardProgramsSet.has(flag)}
onCheckedChange={(checked) =>
toggleBoardProgramEntry(flag, checked)
}
/>
</div>
);
})}
</div>
)}
<div className="grid gap-2">
<HelpTip label="Dry-run only — the weekly bot runs this in a throwaway clone to detect a lockfile diff; nothing is committed until it opens a task that rides the normal PR-review flow.">
<Label htmlFor="dep_update_command">
+31
View File
@@ -0,0 +1,31 @@
import api from "./client";
// ---------------------------------------------------------------------------
// Board Programs — the generic registry (roadmap, x_feature today) the CEO
// monitors and can run off-schedule. See roboco/api/routes/board_programs.py.
// ---------------------------------------------------------------------------
export interface BoardProgram {
key: string;
role: string;
trigger: string;
scope: string;
enabled: boolean;
opted_in_project_slugs: string[];
last_opened_at: string | null;
open_cycle: boolean;
last_cycle_summary: string | null;
}
export const boardProgramsApi = {
list: async (): Promise<BoardProgram[]> => {
const { data } = await api.get<BoardProgram[]>("/board-programs");
return data;
},
runNow: async (key: string): Promise<BoardProgram> => {
const { data } = await api.post<BoardProgram>(
`/board-programs/${key}/run-now`,
);
return data;
},
};
+2
View File
@@ -45,6 +45,8 @@ export type {
RoadmapItem,
RoadmapItemActionResult,
} from "./roadmap";
export { boardProgramsApi } from "./board-programs";
export type { BoardProgram } from "./board-programs";
export { videoApi, videoMediaUrl } from "./video";
export type {
VideoCut,
+1
View File
@@ -177,6 +177,7 @@ export const projectsApi = {
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
board_programs: null,
workspace_path: null,
last_synced_at: null,
head_commit: null,
+6
View File
@@ -1075,6 +1075,11 @@ export interface Project {
monthly_spend_usd?: number | null;
sandbox_services: string[] | null;
sandbox_extensions: Record<string, string[]> | null;
// Board Program per-project scoping: a plain program key opts this project
// INTO that project-scoped program's cycles; a '!'-prefixed org-scoped key
// excludes this project from that program's output. Null = default
// (participates in nothing, excluded from nothing).
board_programs: string[] | null;
// Runtime state
workspace_path: string | null;
last_synced_at: string | null;
@@ -1141,6 +1146,7 @@ export interface ProjectUpdate {
monthly_budget_usd?: number | null;
sandbox_services?: string[];
sandbox_extensions?: Record<string, string[]>;
board_programs?: string[];
}
export interface ProjectTaskCounts {