mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -0,0 +1,61 @@
|
||||
"""Add board_program_cycles table — the Board Program registry's LEARN ledger.
|
||||
|
||||
One row per program cycle (roadmap, x_feature, and every later registry
|
||||
entry). ``BoardProgramEngine`` dedups one open cycle per program off this
|
||||
table (``closed_at IS NULL``) instead of each engine growing its own
|
||||
open-cycle query, and accrues per-item approve/reject outcomes into
|
||||
``decisions`` so the next cycle's exploration prompt can reference prior
|
||||
rejections. Additive; inert until the Task-3 service starts writing to it.
|
||||
|
||||
Revision ID: 087_board_program_cycles
|
||||
Revises: 086_enable_gemini_provider
|
||||
Create Date: 2026-07-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "087_board_program_cycles"
|
||||
down_revision = "086_enable_gemini_provider"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"board_program_cycles",
|
||||
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("program_key", sa.String(length=40), nullable=False),
|
||||
sa.Column("exploration_task_id", sa.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column(
|
||||
"opened_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("items_proposed", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("items_approved", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("items_rejected", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"decisions", sa.JSON(), nullable=False, server_default=sa.text("'[]'::json")
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["exploration_task_id"], ["tasks.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_board_program_cycles_program_key",
|
||||
"board_program_cycles",
|
||||
["program_key"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_board_program_cycles_program_key", table_name="board_program_cycles"
|
||||
)
|
||||
op.drop_table("board_program_cycles")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Add projects.board_programs — per-project Board Program scoping.
|
||||
|
||||
A project-scoped program key ("pest_control") opts a project INTO that
|
||||
program's cycles (affirmative opt-in, null = out). An org-scoped key
|
||||
prefixed "!" ("!roadmap") opts a project OUT of that program's output
|
||||
(default-eligible, null = in). See
|
||||
``roboco.foundation.policy.board_programs.project_participates``. Additive,
|
||||
nullable; inert until a project sets it or a project-scoped program lands.
|
||||
|
||||
Revision ID: 088_project_board_programs
|
||||
Revises: 087_board_program_cycles
|
||||
Create Date: 2026-07-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision = "088_project_board_programs"
|
||||
down_revision = "087_board_program_cycles"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column("board_programs", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("projects", "board_programs")
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ from roboco.api.middleware import setup_middleware
|
||||
from roboco.api.routes.a2a import router as a2a_router
|
||||
from roboco.api.routes.a2a import wellknown_router as a2a_wellknown_router
|
||||
from roboco.api.routes.agents import router as agents_router
|
||||
from roboco.api.routes.board_programs import router as board_programs_router
|
||||
from roboco.api.routes.cockpit import router as cockpit_router
|
||||
from roboco.api.routes.company_goals import router as company_goals_router
|
||||
from roboco.api.routes.dashboard import router as dashboard_router
|
||||
@@ -464,6 +465,14 @@ def create_app() -> FastAPI:
|
||||
tags=["Roadmap"],
|
||||
)
|
||||
|
||||
# Board Programs — the generic registry status + off-schedule "run now"
|
||||
# (roadmap + x_feature today; every later program rides the same route).
|
||||
app.include_router(
|
||||
board_programs_router,
|
||||
prefix=f"{api_prefix}/board-programs",
|
||||
tags=["Board Programs"],
|
||||
)
|
||||
|
||||
# Video engine — the CEO requests an on-demand marketing video; the
|
||||
# release/spotlight triggers open the same UX/UI authoring task via their
|
||||
# own hooks. Nothing renders or posts from this route alone.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Board Programs API — CEO-only registry status + off-schedule "run now".
|
||||
|
||||
Mirrors ``roboco/api/routes/roadmap.py``'s CEO-gating shape. Lists every
|
||||
registered program (``roboco.foundation.policy.board_programs.PROGRAMS``)
|
||||
with its live settings-store enablement, dedup/open-cycle state, and
|
||||
opted-in projects; ``run-now`` calls ``BoardProgramEngine.open_program_cycle``
|
||||
off-schedule (enabled + dedup only, no cron-due check) — the same seam the
|
||||
strategy-engine idle trigger uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.foundation.policy.board_programs import PROGRAMS
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.board_programs import BoardProgramEngine, get_board_program_engine
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="view or act on Board Programs")
|
||||
|
||||
|
||||
class BoardProgramResponse(BaseModel):
|
||||
"""One registry entry's live status — the panel card + edit-project
|
||||
dialog's opt-in controls both read this shape."""
|
||||
|
||||
key: str
|
||||
role: str
|
||||
trigger: str
|
||||
scope: str
|
||||
enabled: bool
|
||||
opted_in_project_slugs: list[str]
|
||||
last_opened_at: str | None
|
||||
open_cycle: bool
|
||||
last_cycle_summary: str | None
|
||||
|
||||
|
||||
async def _to_response(engine: BoardProgramEngine, key: str) -> BoardProgramResponse:
|
||||
program = PROGRAMS[key]
|
||||
enabled = await engine.enabled(key)
|
||||
open_cycle, last_opened_at = await engine.cycle_state(key)
|
||||
summary = await engine.prior_cycle_context(key, limit=1)
|
||||
opted_in = await engine.opted_in_projects(program)
|
||||
return BoardProgramResponse(
|
||||
key=key,
|
||||
role=program.role,
|
||||
trigger=program.trigger.value,
|
||||
scope=program.scope,
|
||||
enabled=enabled,
|
||||
opted_in_project_slugs=[p.slug for p in opted_in],
|
||||
last_opened_at=last_opened_at.isoformat() if last_opened_at else None,
|
||||
open_cycle=open_cycle,
|
||||
last_cycle_summary=summary or None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BoardProgramResponse])
|
||||
async def list_board_programs(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> list[BoardProgramResponse]:
|
||||
"""Every registered Board Program's live status."""
|
||||
_require_ceo(agent)
|
||||
engine = get_board_program_engine(db)
|
||||
return [await _to_response(engine, key) for key in PROGRAMS]
|
||||
|
||||
|
||||
@router.post("/{key}/run-now", response_model=BoardProgramResponse)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
async def run_program_now(
|
||||
key: str, db: DbSession, agent: CurrentAgentContext
|
||||
) -> BoardProgramResponse:
|
||||
"""Open a cycle for ``key`` off-schedule.
|
||||
|
||||
404 for an unregistered key; 409 when the program is disabled, already
|
||||
has an open cycle, or (a project-scoped program) has no opted-in project
|
||||
— ``open_program_cycle`` collapses all three into the same None result,
|
||||
and the caller has no actionable distinction between them beyond retry.
|
||||
"""
|
||||
_require_ceo(agent)
|
||||
if key not in PROGRAMS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Unknown Board Program"
|
||||
)
|
||||
engine = get_board_program_engine(db)
|
||||
task = await engine.open_program_cycle(key)
|
||||
if task is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Could not open a cycle — the program may be disabled, already "
|
||||
"have an open cycle, or have no opted-in project"
|
||||
),
|
||||
)
|
||||
# Write route commits explicitly (get_db auto-commit is unreliable).
|
||||
await db.commit()
|
||||
return await _to_response(engine, key)
|
||||
@@ -65,6 +65,7 @@ class ProjectResponse(BaseModel):
|
||||
monthly_spend_usd: float | None = None
|
||||
sandbox_services: list[str] | None = None
|
||||
sandbox_extensions: dict[str, list[str]] | None = None
|
||||
board_programs: list[str] | None = None
|
||||
|
||||
# Runtime state
|
||||
workspace_path: str | None = None
|
||||
@@ -221,6 +222,7 @@ class ProjectUpdateRequest(BaseModel):
|
||||
monthly_budget_usd: float | None = Field(default=None, gt=0)
|
||||
sandbox_services: list[str] | None = None
|
||||
sandbox_extensions: dict[str, list[str]] | None = None
|
||||
board_programs: list[str] | None = None
|
||||
|
||||
# State
|
||||
is_active: bool | None = None
|
||||
@@ -314,6 +316,7 @@ def project_to_response(project: "ProjectTable") -> ProjectResponse:
|
||||
monthly_budget_usd=getattr(project, "monthly_budget_usd", None),
|
||||
sandbox_services=project.sandbox_services,
|
||||
sandbox_extensions=project.sandbox_extensions,
|
||||
board_programs=project.board_programs,
|
||||
workspace_path=project.workspace_path,
|
||||
last_synced_at=project.last_synced_at,
|
||||
head_commit=project.head_commit,
|
||||
|
||||
@@ -588,6 +588,15 @@ class ProjectTable(Base):
|
||||
JSONB, nullable=True
|
||||
)
|
||||
|
||||
# Board Program per-project scoping (migration 088). A project-scoped
|
||||
# program key ("pest_control") opts this project INTO that program's
|
||||
# cycles; an org-scoped key prefixed "!" ("!roadmap") opts this project
|
||||
# OUT of that program's output. Null = participates in no project-scoped
|
||||
# program and is excluded from no org-scoped program's output (parity
|
||||
# default). Validated by the Project pydantic model
|
||||
# (validate_board_programs_field).
|
||||
board_programs: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# Access Control
|
||||
assigned_cell: Mapped[Team] = mapped_column(_str_enum(Team), nullable=False)
|
||||
allowed_agents: Mapped[list[PyUUID] | None] = mapped_column(
|
||||
@@ -2547,6 +2556,48 @@ class VaultSeenNoteTable(Base):
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# BOARD PROGRAMS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BoardProgramCycleTable(Base):
|
||||
"""LEARN ledger — one row per Board Program cycle (roadmap, x_feature, ...).
|
||||
|
||||
``BoardProgramEngine.run_due_programs`` opens a row when a program's
|
||||
originate callable (``RoadmapEngine.run_cycle`` / ``XEngine.
|
||||
open_feature_spotlight_exploration``) returns a task; an open row
|
||||
(``closed_at IS NULL``) is the one-cycle-at-a-time dedup gate. ``decisions``
|
||||
is append-only: one entry per CEO approve/reject, ``{item_ref, verdict,
|
||||
reason?}``. ``exploration_task_id`` is nullable so a deleted task doesn't
|
||||
take cycle history with it.
|
||||
"""
|
||||
|
||||
__tablename__ = "board_program_cycles"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
program_key: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
exploration_task_id: Mapped[UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
opened_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
items_proposed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
items_approved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
items_rejected: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON, nullable=False, default=list
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TIKTOK ACCOUNT TABLES
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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)
|
||||
@@ -12,6 +12,7 @@ from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from roboco.foundation.policy.board_programs import validate_board_programs_field
|
||||
from roboco.models.base import RobocoBase, Team, TimestampMixin
|
||||
from roboco.models.env_branches import normalize_environments
|
||||
from roboco.models.sandbox import (
|
||||
@@ -291,6 +292,25 @@ class Project(TimestampMixin):
|
||||
) -> dict[str, list[str]] | None:
|
||||
return _normalize_sandbox_extensions(v)
|
||||
|
||||
# Board Program per-project scoping (Task 6b). A project-scoped program
|
||||
# key opts this project INTO its cycles; an org-scoped key prefixed "!"
|
||||
# opts this project OUT of its output. Null = parity default (no
|
||||
# project-scoped participation, no org-scoped exclusion).
|
||||
board_programs: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Board Program scoping: a plain program key opts this project "
|
||||
"into a project-scoped program's cycles; a '!'-prefixed org-"
|
||||
"scoped key excludes this project from that program's output. "
|
||||
"Null = participates in nothing, excluded from nothing."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("board_programs")
|
||||
@classmethod
|
||||
def _check_board_programs(cls, v: list[str] | None) -> list[str] | None:
|
||||
return validate_board_programs_field(v)
|
||||
|
||||
# Metadata
|
||||
created_by: UUID = Field(..., description="PM who registered the project")
|
||||
is_active: bool = Field(default=True, description="Whether project is active")
|
||||
@@ -375,6 +395,7 @@ class ProjectUpdate(RobocoBase):
|
||||
monthly_budget_usd: float | None = Field(default=None, gt=0)
|
||||
sandbox_services: list[str] | None = None
|
||||
sandbox_extensions: dict[str, list[str]] | None = None
|
||||
board_programs: list[str] | None = None
|
||||
github_installation_id: int | None = Field(
|
||||
default=None,
|
||||
description="GitHub App installation id covering this repo (see Project).",
|
||||
@@ -392,6 +413,11 @@ class ProjectUpdate(RobocoBase):
|
||||
) -> dict[str, list[str]] | None:
|
||||
return _normalize_sandbox_extensions(v)
|
||||
|
||||
@field_validator("board_programs")
|
||||
@classmethod
|
||||
def _check_board_programs(cls, v: list[str] | None) -> list[str] | None:
|
||||
return validate_board_programs_field(v)
|
||||
|
||||
@field_validator("environments")
|
||||
@classmethod
|
||||
def _check_environments(
|
||||
|
||||
@@ -1220,8 +1220,7 @@ class AgentOrchestrator:
|
||||
self._env_sync_task: asyncio.Task | None = None
|
||||
self._release_manager_task: asyncio.Task | None = None
|
||||
self._x_mentions_task: asyncio.Task | None = None
|
||||
self._roadmap_engine_task: asyncio.Task | None = None
|
||||
self._x_feature_spotlight_task: asyncio.Task | None = None
|
||||
self._board_program_task: asyncio.Task | None = None
|
||||
self._video_render_task: asyncio.Task | None = None
|
||||
self._vault_intake_task: asyncio.Task | None = None
|
||||
self._vault_janitor_task: asyncio.Task | None = None
|
||||
@@ -1318,10 +1317,7 @@ class AgentOrchestrator:
|
||||
self._env_sync_task = asyncio.create_task(self._env_sync_loop())
|
||||
self._release_manager_task = asyncio.create_task(self._release_manager_loop())
|
||||
self._x_mentions_task = asyncio.create_task(self._x_mentions_poll_loop())
|
||||
self._roadmap_engine_task = asyncio.create_task(self._roadmap_engine_loop())
|
||||
self._x_feature_spotlight_task = asyncio.create_task(
|
||||
self._x_feature_spotlight_loop()
|
||||
)
|
||||
self._board_program_task = asyncio.create_task(self._board_program_loop())
|
||||
self._video_render_task = asyncio.create_task(self._video_render_loop())
|
||||
self._vault_intake_task = asyncio.create_task(self._vault_intake_loop())
|
||||
self._vault_janitor_task = asyncio.create_task(self._vault_janitor_loop())
|
||||
@@ -1436,8 +1432,7 @@ class AgentOrchestrator:
|
||||
self._env_sync_task,
|
||||
self._release_manager_task,
|
||||
self._x_mentions_task,
|
||||
self._roadmap_engine_task,
|
||||
self._x_feature_spotlight_task,
|
||||
self._board_program_task,
|
||||
self._video_render_task,
|
||||
self._vault_intake_task,
|
||||
self._vault_janitor_task,
|
||||
@@ -9073,37 +9068,53 @@ Start by:
|
||||
await get_x_engine(db).run_cycle()
|
||||
await db.commit()
|
||||
|
||||
async def _roadmap_engine_loop(self) -> None:
|
||||
"""Board roadmap engine: on an interval, open ONE held exploration cycle.
|
||||
async def _board_program_loop(self) -> None:
|
||||
"""Board Program engine: on an interval, originate a cycle for every
|
||||
enabled, due program (roadmap, x_feature, and every later registry
|
||||
entry) — replaces the old bespoke ``_roadmap_engine_loop`` /
|
||||
``_x_feature_spotlight_loop``.
|
||||
|
||||
Dormant by default — returns immediately unless ``roadmap_engine_enabled``,
|
||||
so a standard deployment originates nothing. The engine itself only opens
|
||||
the held exploration task; the Product Owner authors the themed cycle
|
||||
(``propose_roadmap``) once the board dispatcher spawns it, and approved
|
||||
items land in BACKLOG only via the CEO's per-item approve — this loop
|
||||
never starts anything.
|
||||
Unlike those, this loop carries no single static disablement gate:
|
||||
each program's own enablement (legacy flag or settings-store
|
||||
override) is checked per-tick inside ``BoardProgramEngine``, so the
|
||||
loop always ticks and simply originates nothing when every program
|
||||
is off. The tick interval is a fixed floor, not a live setting — it
|
||||
only bounds how promptly a newly-due program is noticed; the actual
|
||||
due-check inside the engine still reads the live per-program
|
||||
interval override.
|
||||
"""
|
||||
if not settings.roadmap_engine_enabled:
|
||||
return
|
||||
interval = settings.roadmap_interval_seconds
|
||||
self._record_loop_heartbeat("roadmap_engine", interval)
|
||||
interval = self._board_program_interval_seconds()
|
||||
self._record_loop_heartbeat("board_program", interval)
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await self._run_roadmap_engine_cycle()
|
||||
self._record_loop_heartbeat("roadmap_engine", interval)
|
||||
await self._run_board_program_cycle()
|
||||
self._record_loop_heartbeat("board_program", interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("roadmap-engine cycle failed")
|
||||
logger.exception("board-program cycle failed")
|
||||
|
||||
async def _run_roadmap_engine_cycle(self) -> None:
|
||||
"""One roadmap-engine pass: run the engine, commit. Testable w/o the sleep."""
|
||||
def _board_program_interval_seconds(self) -> int:
|
||||
"""Loop wake-up floor: the shortest registered program cadence,
|
||||
floored at 300s (so an idle deployment doesn't busy-poll) and capped
|
||||
at 3600s — due-ness staleness is bounded at 1h; ticks are cheap
|
||||
settings reads, so a slower program cadence never needs a slower
|
||||
tick."""
|
||||
from roboco.foundation.policy.board_programs import PROGRAMS
|
||||
|
||||
shortest = min(
|
||||
(p.default_interval_seconds for p in PROGRAMS.values()), default=300
|
||||
)
|
||||
return min(3600, max(300, shortest))
|
||||
|
||||
async def _run_board_program_cycle(self) -> None:
|
||||
"""One board-program pass: run the engine, commit. Testable w/o the sleep."""
|
||||
from roboco.db import get_db_context
|
||||
from roboco.services.roadmap_engine import get_roadmap_engine
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
async with get_db_context() as db:
|
||||
await get_roadmap_engine(db).run_cycle()
|
||||
await get_board_program_engine(db).run_due_programs()
|
||||
await db.commit()
|
||||
|
||||
async def _vault_intake_loop(self) -> None:
|
||||
@@ -9232,37 +9243,6 @@ Start by:
|
||||
await get_telegram_inbound_engine(db).run_cycle()
|
||||
await db.commit()
|
||||
|
||||
async def _x_feature_spotlight_loop(self) -> None:
|
||||
"""X engine: on an interval, open ONE held feature-spotlight exploration
|
||||
for the Head of Marketing.
|
||||
|
||||
Dormant by default — returns immediately unless BOTH x_engine_enabled and
|
||||
x_feature_spotlight_enabled, so a standard deployment (or one running only
|
||||
release posts / mention replies) never spawns HoM for this.
|
||||
"""
|
||||
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
|
||||
return
|
||||
interval = settings.x_feature_spotlight_interval_seconds
|
||||
self._record_loop_heartbeat("x_feature_spotlight", interval)
|
||||
while self._running:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await self._run_x_feature_spotlight_cycle()
|
||||
self._record_loop_heartbeat("x_feature_spotlight", interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("x-feature-spotlight cycle failed")
|
||||
|
||||
async def _run_x_feature_spotlight_cycle(self) -> None:
|
||||
"""One feature-spotlight pass: run the engine, commit. Testable w/o sleep."""
|
||||
from roboco.db import get_db_context
|
||||
from roboco.services.x_engine import get_x_engine
|
||||
|
||||
async with get_db_context() as db:
|
||||
await get_x_engine(db).open_feature_spotlight_exploration()
|
||||
await db.commit()
|
||||
|
||||
async def _video_render_loop(self) -> None:
|
||||
"""Video engine: on an interval, render merged compositions to MP4 and
|
||||
materialize held video_post drafts.
|
||||
@@ -12762,14 +12742,35 @@ Start now: evidence(task_id="{task_id}")
|
||||
return
|
||||
self._board_dispatched.add(key)
|
||||
logger.info("Spawning Product Owner for roadmap exploration", task_id=task_id)
|
||||
prior_context = await self._board_program_prior_context("roadmap")
|
||||
await self.spawn_agent(
|
||||
agent_id=po_slug,
|
||||
task_id=task["id"],
|
||||
initial_prompt=self._build_roadmap_prompt(task),
|
||||
initial_prompt=self._build_roadmap_prompt(task, prior_context),
|
||||
git_context=self._task_git_context(task),
|
||||
spawned_by="_dispatch_roadmap_exploration",
|
||||
)
|
||||
|
||||
async def _board_program_prior_context(self, program_key: str) -> str:
|
||||
"""Best-effort LEARN read for prompt injection — mirrors
|
||||
``_pm_respawn_should_gate``'s tracing-gap audit lookup's best-effort
|
||||
DB posture: a read failure here must never block a spawn, only drop
|
||||
the '## Prior cycles' section from this cycle's prompt."""
|
||||
try:
|
||||
from roboco.db import get_db_context
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
async with get_db_context() as db:
|
||||
return await get_board_program_engine(db).prior_cycle_context(
|
||||
program_key
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"board-program: prior-cycle-context read failed (best-effort)",
|
||||
program=program_key,
|
||||
)
|
||||
return ""
|
||||
|
||||
async def _dispatch_feature_spotlight_exploration(
|
||||
self, task: dict[str, Any]
|
||||
) -> None:
|
||||
@@ -12797,10 +12798,11 @@ Start now: evidence(task_id="{task_id}")
|
||||
"Spawning Head of Marketing for feature-spotlight exploration",
|
||||
task_id=task_id,
|
||||
)
|
||||
prior_context = await self._board_program_prior_context("x_feature")
|
||||
await self.spawn_agent(
|
||||
agent_id=hom_slug,
|
||||
task_id=task["id"],
|
||||
initial_prompt=self._build_feature_spotlight_prompt(task),
|
||||
initial_prompt=self._build_feature_spotlight_prompt(task, prior_context),
|
||||
git_context=self._task_git_context(task),
|
||||
spawned_by="_dispatch_feature_spotlight_exploration",
|
||||
)
|
||||
@@ -15646,16 +15648,21 @@ Do NOT attempt to claim, plan, complete, or delegate — the gateway will reject
|
||||
those, and a substantive recorded note IS your job here.
|
||||
"""
|
||||
|
||||
def _build_roadmap_prompt(self, task: dict[str, Any]) -> str:
|
||||
def _build_roadmap_prompt(
|
||||
self, task: dict[str, Any], prior_context: str = ""
|
||||
) -> str:
|
||||
"""Prompt for the Product Owner's one-shot roadmap-exploration cycle.
|
||||
|
||||
Unlike the two-reviewer board-review prompt, this is PO-solo (v1 —
|
||||
see the roadmap spec's non-goals): explore, author ONE themed cycle,
|
||||
then idle. No claim/plan/delegate/complete — those verbs aren't the
|
||||
Product Owner's."""
|
||||
Product Owner's. ``prior_context`` is the LEARN rendering of the last
|
||||
closed cycles (``BoardProgramEngine.prior_cycle_context``) — empty
|
||||
when none exist yet."""
|
||||
task_id = task.get("id", "unknown")
|
||||
min_items = settings.roadmap_min_items_per_cycle
|
||||
max_items = settings.roadmap_max_items_per_cycle
|
||||
prior_block = f"\n## Prior cycles\n{prior_context}\n" if prior_context else ""
|
||||
return f"""\
|
||||
You are the Product Owner. It's time for your periodic roadmap exploration.
|
||||
|
||||
@@ -15664,7 +15671,7 @@ TASK: {task_id}
|
||||
Explore the company's projects and propose ONE themed cycle of roadmap items
|
||||
for the CEO to review — you author this alone. The Head of Marketing is not
|
||||
involved in this cycle.
|
||||
|
||||
{prior_block}
|
||||
== WHAT TO DO ==
|
||||
|
||||
1. triage() — see your board-level context.
|
||||
@@ -15686,13 +15693,20 @@ Do NOT claim, plan, delegate, or attempt to start any of the items yourself —
|
||||
that is not your job here, and the gateway will reject those verbs.
|
||||
"""
|
||||
|
||||
def _build_feature_spotlight_prompt(self, task: dict[str, Any]) -> str:
|
||||
"""Prompt for the Head of Marketing's one-shot feature-spotlight cycle."""
|
||||
def _build_feature_spotlight_prompt(
|
||||
self, task: dict[str, Any], prior_context: str = ""
|
||||
) -> str:
|
||||
"""Prompt for the Head of Marketing's one-shot feature-spotlight cycle.
|
||||
|
||||
``prior_context`` is the LEARN rendering of the last closed cycles
|
||||
(``BoardProgramEngine.prior_cycle_context``) — empty when none exist
|
||||
yet."""
|
||||
task_id = task.get("id", "unknown")
|
||||
markers_dict = task.get("orchestration_markers") or {}
|
||||
seen_line = _format_seen_features(markers_dict)
|
||||
shipped_line = _format_shipped_since(markers_dict)
|
||||
rejected_line = _format_rejected_spotlights(markers_dict)
|
||||
prior_block = f"\n## Prior cycles\n{prior_context}\n" if prior_context else ""
|
||||
return f"""\
|
||||
You are the Head of Marketing. It's time for your periodic feature-spotlight cycle.
|
||||
|
||||
@@ -15709,7 +15723,7 @@ ALREADY COVERED — do not repeat: {seen_line}
|
||||
SHIPPED SINCE THE LAST CYCLE (CHANGELOG.md): {shipped_line}
|
||||
|
||||
RECENTLY REJECTED BY THE CEO — avoid repeating these angles: {rejected_line}
|
||||
|
||||
{prior_block}
|
||||
== WHAT TO DO ==
|
||||
|
||||
1. triage() — see your board-level context.
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
"""BoardProgramEngine — the generic trigger/dedup/originate/LEARN engine every
|
||||
Board Program registry entry (``roboco.foundation.policy.board_programs``)
|
||||
rides, replacing per-engine loops + dedup ledgers.
|
||||
|
||||
``run_due_programs`` is what the orchestrator's ``_board_program_loop`` calls
|
||||
on a tick: for each CRON program, check enabled, dedup against the
|
||||
``board_program_cycles`` ledger, check the cron interval, and — on due —
|
||||
delegate origination to the program's proven callable (``RoadmapEngine.
|
||||
run_cycle`` / ``XEngine.open_feature_spotlight_exploration``). This engine
|
||||
never authors content itself, same posture as the engines it wraps.
|
||||
|
||||
Dedup note: an open ledger row (``closed_at IS NULL``) blocks a new cycle,
|
||||
but a row is only a REAL block while its exploration task is still
|
||||
non-terminal. A task going terminal (COMPLETED/CANCELLED) — the roadmap
|
||||
service's own "every item decided" rule, or the X engine completing the
|
||||
exploration the instant ``propose_feature_spotlight`` runs — auto-closes the
|
||||
row on the next check. Without this, a ledger row can outlive the condition
|
||||
it was tracking (e.g. an x_feature exploration completes at propose time,
|
||||
long before the CEO decides the materialized draft) and would otherwise wedge
|
||||
the program's dedup forever, a regression from the per-engine dedup this
|
||||
replaces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import BoardProgramCycleTable, ProjectTable
|
||||
from roboco.foundation.policy.board_programs import (
|
||||
PROGRAMS,
|
||||
TriggerKind,
|
||||
program_due,
|
||||
project_participates,
|
||||
)
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.settings import get_settings_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.foundation.policy.board_programs import BoardProgram
|
||||
|
||||
_TERMINAL_STATUSES = (TaskStatus.COMPLETED, TaskStatus.CANCELLED)
|
||||
|
||||
|
||||
async def _originate_roadmap(session: AsyncSession) -> TaskTable | None:
|
||||
from roboco.services.roadmap_engine import get_roadmap_engine
|
||||
|
||||
return await get_roadmap_engine(session).run_cycle()
|
||||
|
||||
|
||||
async def _originate_x_feature(session: AsyncSession) -> TaskTable | None:
|
||||
from roboco.services.x_engine import get_x_engine
|
||||
|
||||
return await get_x_engine(session).open_feature_spotlight_exploration()
|
||||
|
||||
|
||||
# Origination bindings live here, not in the pure foundation registry — one
|
||||
# entry per PROGRAMS key, asserted by tests. Each program's ``source`` is
|
||||
# separately asserted equal to the service-layer constant it duplicates
|
||||
# (ROADMAP_SOURCE, X_FEATURE_EXPLORATION_SOURCE) so the two can't drift.
|
||||
_ORIGINATORS: dict[str, Callable[[AsyncSession], Awaitable[TaskTable | None]]] = {
|
||||
"roadmap": _originate_roadmap,
|
||||
"x_feature": _originate_x_feature,
|
||||
}
|
||||
|
||||
|
||||
def _legacy_enabled(key: str) -> bool:
|
||||
"""The pre-registry flag(s) each program aliases while both exist."""
|
||||
if key == "roadmap":
|
||||
return settings.roadmap_engine_enabled
|
||||
if key == "x_feature":
|
||||
return settings.x_engine_enabled and settings.x_feature_spotlight_enabled
|
||||
return False
|
||||
|
||||
|
||||
def _interval_override(key: str) -> int | None:
|
||||
"""Per-program configured cadence, when the operator has one set."""
|
||||
if key == "roadmap":
|
||||
return settings.roadmap_interval_seconds
|
||||
if key == "x_feature":
|
||||
return settings.x_feature_spotlight_interval_seconds
|
||||
return None
|
||||
|
||||
|
||||
async def program_armed(session: AsyncSession, key: str) -> bool:
|
||||
"""Whether program ``key`` is armed: the settings-store per-program
|
||||
override when a row exists, else the legacy boot flag(s) it aliases.
|
||||
|
||||
THE single chokepoint every origination gate must route through —
|
||||
``BoardProgramEngine.enabled`` (below), ``RoadmapEngine.run_cycle``,
|
||||
``XEngine.open_feature_spotlight_exploration``, and — via
|
||||
``BoardProgramEngine.open_program_cycle`` — the strategy-engine's idle
|
||||
trigger. Before this existed, ``run_cycle``/``open_feature_spotlight_
|
||||
exploration`` re-checked their OWN legacy flag internally instead of
|
||||
this resolver, so a settings-store-True + legacy-False combination (the
|
||||
exact state the shipped panel toggle produces) silently originated
|
||||
nothing forever.
|
||||
"""
|
||||
return await get_settings_service(session).get_bool(
|
||||
f"board_program.{key}.enabled", _legacy_enabled(key)
|
||||
)
|
||||
|
||||
|
||||
class BoardProgramEngine(BaseService):
|
||||
"""Trigger/dedup/originate/LEARN over every registered Board Program."""
|
||||
|
||||
service_name = "board_program_engine"
|
||||
|
||||
async def enabled(self, key: str) -> bool:
|
||||
"""Per-program settings-store override, else the legacy flag."""
|
||||
return await program_armed(self.session, key)
|
||||
|
||||
async def run_due_programs(self) -> list[str]:
|
||||
"""Originate a cycle for every enabled, due CRON program.
|
||||
|
||||
Returns the keys that opened a new cycle. One program's failure is
|
||||
logged and never blocks the rest — mirrors the CI-watch sweep.
|
||||
"""
|
||||
opened: list[str] = []
|
||||
now = datetime.now(UTC)
|
||||
for key, program in PROGRAMS.items():
|
||||
if program.trigger is not TriggerKind.CRON:
|
||||
continue
|
||||
try:
|
||||
if await self._run_due_one(key, now):
|
||||
opened.append(key)
|
||||
except Exception:
|
||||
self.log.exception("board-program cycle failed", program=key)
|
||||
return opened
|
||||
|
||||
async def _run_due_one(self, key: str, now: datetime) -> bool:
|
||||
if not await self.enabled(key):
|
||||
return False
|
||||
program = PROGRAMS[key]
|
||||
if not await self._scope_gate(program):
|
||||
return False
|
||||
blocked, last_opened_at = await self._dedup_state(key)
|
||||
if blocked:
|
||||
return False
|
||||
if not program_due(
|
||||
program,
|
||||
now=now,
|
||||
last_opened_at=last_opened_at,
|
||||
interval_override=_interval_override(key),
|
||||
):
|
||||
return False
|
||||
return await self._originate_and_record(key) is not None
|
||||
|
||||
async def open_program_cycle(self, key: str) -> TaskTable | None:
|
||||
"""Originate a cycle for ``key`` off-schedule (enabled + dedup only,
|
||||
no cron-due check) — the strategy-engine trigger + "run now" seam."""
|
||||
if key not in PROGRAMS or not await self.enabled(key):
|
||||
return None
|
||||
program = PROGRAMS[key]
|
||||
if not await self._scope_gate(program):
|
||||
return None
|
||||
blocked, _ = await self._dedup_state(key)
|
||||
if blocked:
|
||||
return None
|
||||
return await self._originate_and_record(key)
|
||||
|
||||
async def _scope_gate(self, program: BoardProgram) -> bool:
|
||||
"""Project-scoped programs need at least one opted-in project before
|
||||
a cycle is worth opening; org-scoped programs have no run-side gate
|
||||
(their scoping is output-side only — see ``project_participates``)."""
|
||||
if program.scope != "project":
|
||||
return True
|
||||
if await self.opted_in_projects(program):
|
||||
return True
|
||||
self.log.info(
|
||||
"board-program: no project opted in, skipping cycle", program=program.key
|
||||
)
|
||||
return False
|
||||
|
||||
async def opted_in_projects(self, program: BoardProgram) -> list[ProjectTable]:
|
||||
"""Active projects where ``project_participates(program, ...)`` holds."""
|
||||
result = await self.session.execute(
|
||||
select(ProjectTable).where(ProjectTable.is_active.is_(True))
|
||||
)
|
||||
return [
|
||||
p
|
||||
for p in result.scalars().all()
|
||||
if project_participates(program, p.board_programs)
|
||||
]
|
||||
|
||||
async def cycle_state(self, key: str) -> tuple[bool, datetime | None]:
|
||||
"""(open_cycle, last_opened_at) — reconciled via the same auto-close
|
||||
dedup logic ``run_due_programs``/``open_program_cycle`` consult, so a
|
||||
reader (the API/panel) sees exactly what a "run now" call would."""
|
||||
return await self._dedup_state(key)
|
||||
|
||||
async def record_decision(
|
||||
self,
|
||||
program_key: str,
|
||||
item_ref: str,
|
||||
verdict: str,
|
||||
reason: str | None = None,
|
||||
*,
|
||||
exploration_task_id: UUID | None = None,
|
||||
) -> None:
|
||||
"""Accrue one CEO approve/reject onto a cycle for this program.
|
||||
|
||||
When ``exploration_task_id`` is given, targets the cycle row for
|
||||
THAT exploration task exactly (regardless of open/closed) — the
|
||||
caller holds the real originating task in hand (e.g. RoadmapService,
|
||||
reading the item off its own exploration task), so attribution stays
|
||||
exact even when a newer cycle has since opened for the same program
|
||||
(e.g. the CEO's decision lands after the original cycle auto-closed
|
||||
with undecided items — the admin-cancel edge — and a fresh cycle
|
||||
already opened in the meantime). Falls back to the most RECENT cycle
|
||||
(open or closed) when omitted or unresolved — x_post_service's X
|
||||
drafts don't carry their originating exploration task id, so this is
|
||||
the original, unchanged fallback for that caller. A best-effort
|
||||
no-op when no matching cycle exists.
|
||||
"""
|
||||
cycle = None
|
||||
if exploration_task_id is not None:
|
||||
cycle = await self._cycle_for_exploration(program_key, exploration_task_id)
|
||||
if cycle is None:
|
||||
cycle = await self._latest_cycle(program_key)
|
||||
if cycle is None:
|
||||
return
|
||||
cycle.items_proposed += 1
|
||||
if verdict == "approved":
|
||||
cycle.items_approved += 1
|
||||
else:
|
||||
cycle.items_rejected += 1
|
||||
cycle.decisions = [
|
||||
*cycle.decisions,
|
||||
{"item_ref": item_ref, "verdict": verdict, "reason": reason},
|
||||
]
|
||||
if cycle.closed_at is None:
|
||||
await self._maybe_close(cycle)
|
||||
await self.session.flush()
|
||||
|
||||
async def prior_cycle_context(self, program_key: str, limit: int = 2) -> str:
|
||||
"""Render the last ``limit`` CLOSED cycles for prompt injection, oldest
|
||||
first; empty string when none exist yet."""
|
||||
result = await self.session.execute(
|
||||
select(BoardProgramCycleTable)
|
||||
.where(
|
||||
BoardProgramCycleTable.program_key == program_key,
|
||||
BoardProgramCycleTable.closed_at.isnot(None),
|
||||
)
|
||||
.order_by(BoardProgramCycleTable.closed_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
cycles = list(result.scalars().all())
|
||||
if not cycles:
|
||||
return ""
|
||||
return "\n".join(self._render_cycle(c) for c in reversed(cycles))
|
||||
|
||||
def _render_cycle(self, cycle: BoardProgramCycleTable) -> str:
|
||||
line = f"proposed {cycle.items_proposed}, approved {cycle.items_approved}"
|
||||
rejected = [d for d in cycle.decisions if d.get("verdict") == "rejected"]
|
||||
reasons = "; ".join(
|
||||
f"{d.get('item_ref')} — {d.get('reason')}"
|
||||
for d in rejected
|
||||
if d.get("reason")
|
||||
)
|
||||
if reasons:
|
||||
line += f"; rejected: {reasons}"
|
||||
return line
|
||||
|
||||
# ---- dedup / ledger plumbing --------------------------------------
|
||||
|
||||
async def _dedup_state(self, key: str) -> tuple[bool, datetime | None]:
|
||||
"""(blocked, last_opened_at) — blocked when a still-genuinely-open
|
||||
cycle row exists after an attempted auto-close."""
|
||||
latest = await self._latest_cycle(key)
|
||||
if latest is None:
|
||||
return False, None
|
||||
if latest.closed_at is None and not await self._maybe_close(latest):
|
||||
return True, latest.opened_at
|
||||
return False, latest.opened_at
|
||||
|
||||
async def _maybe_close(self, cycle: BoardProgramCycleTable) -> bool:
|
||||
"""Close ``cycle`` when its exploration task is terminal (or gone);
|
||||
returns whether it is now closed."""
|
||||
if cycle.exploration_task_id is None:
|
||||
cycle.closed_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
return True
|
||||
task = await get_task_service(self.session).get(
|
||||
cast("UUID", cycle.exploration_task_id)
|
||||
)
|
||||
if task is None or task.status in _TERMINAL_STATUSES:
|
||||
cycle.closed_at = datetime.now(UTC)
|
||||
await self.session.flush()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _latest_cycle(self, key: str) -> BoardProgramCycleTable | None:
|
||||
result = await self.session.execute(
|
||||
select(BoardProgramCycleTable)
|
||||
.where(BoardProgramCycleTable.program_key == key)
|
||||
.order_by(BoardProgramCycleTable.opened_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _cycle_for_exploration(
|
||||
self, key: str, exploration_task_id: UUID
|
||||
) -> BoardProgramCycleTable | None:
|
||||
"""The cycle row that opened FOR this exact exploration task, or None
|
||||
— used by ``record_decision`` for exact attribution over the
|
||||
most-recent fallback."""
|
||||
result = await self.session.execute(
|
||||
select(BoardProgramCycleTable)
|
||||
.where(
|
||||
BoardProgramCycleTable.program_key == key,
|
||||
BoardProgramCycleTable.exploration_task_id == exploration_task_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _originate_and_record(self, key: str) -> TaskTable | None:
|
||||
task = await _ORIGINATORS[key](self.session)
|
||||
if task is None:
|
||||
return None
|
||||
self.session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key=key,
|
||||
exploration_task_id=task.id,
|
||||
opened_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def get_board_program_engine(session: AsyncSession) -> BoardProgramEngine:
|
||||
"""Construct a BoardProgramEngine bound to ``session``."""
|
||||
return BoardProgramEngine(session)
|
||||
@@ -1265,8 +1265,13 @@ class ContentActions:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _reject_roadmap_item(cls, raw: Any, idx: int) -> Envelope | None:
|
||||
"""Validate one raw roadmap item dict; None when clean."""
|
||||
def _reject_roadmap_item_shape(cls, raw: Any, idx: int) -> Envelope | None:
|
||||
"""Validate one raw roadmap item dict's shape/fields; None when clean.
|
||||
|
||||
Synchronous — no DB access. Split from ``_reject_roadmap_item`` (which
|
||||
adds the Task-6b project-exclusion check) so the shape checks stay
|
||||
classmethod-testable without a session.
|
||||
"""
|
||||
if not isinstance(raw, dict):
|
||||
return Envelope.invalid_state(
|
||||
message=f"item {idx} is not an object",
|
||||
@@ -1280,6 +1285,54 @@ class ContentActions:
|
||||
return rej
|
||||
return cls._reject_roadmap_item_team(raw, idx)
|
||||
|
||||
async def _reject_roadmap_item(
|
||||
self, raw: dict[str, Any], idx: int
|
||||
) -> Envelope | None:
|
||||
"""Validate one raw roadmap item dict; None when clean.
|
||||
|
||||
Folds the shape/fields/team checks with the Task-6b project-exclusion
|
||||
check into one call so the ``propose_roadmap`` loop keeps a single
|
||||
return point per item (xenon/PLR0911 budget).
|
||||
"""
|
||||
if rej := self._reject_roadmap_item_shape(raw, idx):
|
||||
return rej
|
||||
return await self._reject_excluded_roadmap_project(raw, idx)
|
||||
|
||||
async def _reject_excluded_roadmap_project(
|
||||
self, raw: dict[str, Any], idx: int
|
||||
) -> Envelope | None:
|
||||
"""Reject an item targeting a project that excluded itself from the
|
||||
roadmap program (``!roadmap`` in its ``board_programs``) — the PO
|
||||
learns this at propose time instead of a silent materialize-time skip.
|
||||
|
||||
An unresolvable ``project_slug`` is NOT rejected here; that surfaces
|
||||
downstream at approve/materialize time as it already did before Task
|
||||
6b (this check only ever narrows an otherwise-valid slug).
|
||||
"""
|
||||
from roboco.foundation.policy.board_programs import (
|
||||
PROGRAMS,
|
||||
project_participates,
|
||||
)
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
slug = str(raw.get("project_slug", "")).strip()
|
||||
project = await get_project_service(self.task.session).get_by_slug(slug)
|
||||
if project is None:
|
||||
return None
|
||||
if not project_participates(PROGRAMS["roadmap"], project.board_programs):
|
||||
return Envelope.invalid_state(
|
||||
message=(
|
||||
f"item {idx} targets project {slug!r}, which excluded "
|
||||
"itself from the roadmap program"
|
||||
),
|
||||
remediate=(
|
||||
f"drop item {idx} or retarget it to a project not "
|
||||
"excluded via '!roadmap'"
|
||||
),
|
||||
context_briefing={},
|
||||
)
|
||||
return None
|
||||
|
||||
async def propose_roadmap(
|
||||
self,
|
||||
*,
|
||||
@@ -1320,7 +1373,7 @@ class ContentActions:
|
||||
)
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for idx, raw in enumerate(items):
|
||||
if rej := self._reject_roadmap_item(raw, idx):
|
||||
if rej := await self._reject_roadmap_item(raw, idx):
|
||||
return rej
|
||||
normalized.append(_normalize_roadmap_item(idx, raw))
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ 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.** ``roadmap_engine_enabled`` is False, so the loop never
|
||||
runs and nothing is originated.
|
||||
* **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.
|
||||
@@ -25,6 +27,7 @@ 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
|
||||
|
||||
@@ -54,12 +57,13 @@ class RoadmapEngine(BaseService):
|
||||
async def run_cycle(self) -> TaskTable | None:
|
||||
"""Originate one held exploration task, or None (no-op).
|
||||
|
||||
No-ops when the flag is off, 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
|
||||
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 settings.roadmap_engine_enabled:
|
||||
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():
|
||||
|
||||
@@ -16,8 +16,9 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from roboco.foundation.policy.board_programs import PROGRAMS, project_participates
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import TaskStatus
|
||||
from roboco.services.base import BaseService
|
||||
@@ -97,6 +98,7 @@ class RoadmapService(BaseService):
|
||||
item["materialized_task_id"] = str(new_task.id)
|
||||
markers.set_roadmap_cycle(task, payload)
|
||||
self._maybe_complete_cycle(task, payload)
|
||||
await self._record_learn(task, item_id, "approved")
|
||||
await self.session.flush()
|
||||
return RoadmapItemResult(
|
||||
status="approved",
|
||||
@@ -135,6 +137,7 @@ class RoadmapService(BaseService):
|
||||
item["reject_reason"] = reason
|
||||
markers.set_roadmap_cycle(task, payload)
|
||||
self._maybe_complete_cycle(task, payload)
|
||||
await self._record_learn(task, item_id, "rejected", reason)
|
||||
await self.session.flush()
|
||||
return RoadmapItemResult(
|
||||
status="rejected",
|
||||
@@ -182,6 +185,14 @@ class RoadmapService(BaseService):
|
||||
)
|
||||
if project is None or project.id is None:
|
||||
raise ValueError(f"unknown project slug: {item['project_slug']!r}")
|
||||
if not project_participates(PROGRAMS["roadmap"], project.board_programs):
|
||||
self.log.warning(
|
||||
"roadmap: materialize skipped — project excluded (!roadmap)",
|
||||
project_slug=item["project_slug"],
|
||||
)
|
||||
raise ValueError(
|
||||
f"project {item['project_slug']!r} is excluded from the roadmap program"
|
||||
)
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
@@ -216,6 +227,29 @@ class RoadmapService(BaseService):
|
||||
audit_agent_id=None,
|
||||
)
|
||||
|
||||
async def _record_learn(
|
||||
self, task: TaskTable, item_id: str, verdict: str, reason: str | None = None
|
||||
) -> None:
|
||||
"""Best-effort LEARN: a record_decision failure must never break the
|
||||
CEO's approve/reject — mirrors the vault-writer best-effort seams.
|
||||
|
||||
Targets ``task`` (this exploration task) by id — exact attribution
|
||||
even when a newer cycle for "roadmap" has since opened, unlike
|
||||
``record_decision``'s most-recent fallback.
|
||||
"""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
await get_board_program_engine(self.session).record_decision(
|
||||
"roadmap",
|
||||
item_id,
|
||||
verdict,
|
||||
reason,
|
||||
exploration_task_id=cast("UUID", task.id),
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("roadmap: LEARN record_decision failed (best-effort)")
|
||||
|
||||
|
||||
def get_roadmap_service(session: AsyncSession) -> RoadmapService:
|
||||
"""Construct a RoadmapService bound to ``session``."""
|
||||
|
||||
@@ -130,6 +130,12 @@ _VALIDATORS = {
|
||||
# flag (absent from FEATURE_FLAGS/the panel card) but reuses this same KV
|
||||
# store instead of a dedicated table or a restart-losing in-memory flag.
|
||||
"x_brand_voice_nudge_sent": _validate_bool,
|
||||
# Board Program per-program enablement (BoardProgramEngine.enabled).
|
||||
# Dotted, not in FEATURE_FLAGS (no roboco.config attribute to fall back
|
||||
# to) — an unset key falls back to the migrated legacy flag instead
|
||||
# (roadmap_engine_enabled / x_engine_enabled+x_feature_spotlight_enabled).
|
||||
"board_program.roadmap.enabled": _validate_bool,
|
||||
"board_program.x_feature.enabled": _validate_bool,
|
||||
**dict.fromkeys(_FEATURE_FLAG_KEYS, _validate_bool),
|
||||
}
|
||||
|
||||
|
||||
@@ -90,7 +90,15 @@ class StrategyEngine(BaseService):
|
||||
return observations
|
||||
|
||||
async def run_cycle(self) -> list[StrategyObservation]:
|
||||
"""Assess and notify the CEO. No-op unless the engine is enabled."""
|
||||
"""Assess and notify the CEO. No-op unless the engine is enabled.
|
||||
|
||||
An ``idle`` observation additionally triggers a roadmap Board Program
|
||||
cycle (``BoardProgramEngine.open_program_cycle`` — enabled+dedup
|
||||
checked there, so a still-open cycle makes this a no-op); the nudge
|
||||
text reflects the outcome instead of only describing the drift.
|
||||
``stranded_blocked`` stays notify-only (Coroner is Phase 2 — its
|
||||
event hook lands then).
|
||||
"""
|
||||
if not settings.strategy_engine_enabled:
|
||||
return []
|
||||
observations = await self.assess()
|
||||
@@ -98,13 +106,40 @@ class StrategyEngine(BaseService):
|
||||
return []
|
||||
notifier = NotificationService()
|
||||
for obs in observations:
|
||||
body = f"[strategy engine] {obs.summary}\n\n{obs.detail}"
|
||||
if obs.kind == "idle":
|
||||
body = f"{body}\n\n{await self._trigger_roadmap_cycle()}"
|
||||
await notifier.send_ack_notification(
|
||||
from_agent="system",
|
||||
to_agent="ceo",
|
||||
body=f"[strategy engine] {obs.summary}\n\n{obs.detail}",
|
||||
body=body,
|
||||
)
|
||||
return observations
|
||||
|
||||
async def _trigger_roadmap_cycle(self) -> str:
|
||||
"""Best-effort: open a roadmap cycle via the Board Program engine.
|
||||
|
||||
A DB/engine failure here must never break the idle notification —
|
||||
degrades to a plain "attempted" line rather than raising.
|
||||
"""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
task = await get_board_program_engine(self.session).open_program_cycle(
|
||||
"roadmap"
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"strategy-engine: roadmap-cycle trigger failed (best-effort)"
|
||||
)
|
||||
return "Attempted to open a roadmap exploration cycle (failed; see logs)."
|
||||
if task is not None:
|
||||
return "A roadmap exploration cycle was opened for the Product Owner."
|
||||
return (
|
||||
"A roadmap exploration cycle is already open (or the roadmap "
|
||||
"program is disabled)."
|
||||
)
|
||||
|
||||
|
||||
def get_strategy_engine(session: AsyncSession) -> StrategyEngine:
|
||||
"""Construct a StrategyEngine bound to ``session``."""
|
||||
|
||||
@@ -49,6 +49,7 @@ from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.injection_guard import screen_external_text
|
||||
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.company_goals import get_company_goals_service
|
||||
from roboco.services.notification_delivery import get_notification_delivery_service
|
||||
from roboco.services.project import get_project_service
|
||||
@@ -705,7 +706,9 @@ class XEngine(BaseService):
|
||||
async def open_feature_spotlight_exploration(self) -> TaskTable | None:
|
||||
"""Originate ONE held exploration task for the Head of Marketing, or None.
|
||||
|
||||
No-ops when the flags are off, no X credentials are configured (drafting
|
||||
No-ops when the program isn't armed (``program_armed`` — settings-store
|
||||
override, else the legacy ``x_engine_enabled``+``x_feature_spotlight_
|
||||
enabled`` pair), no X credentials are configured (drafting
|
||||
content nobody can ever post is pointless — mirrors the release/mentions
|
||||
guard), a materialized spotlight draft is still awaiting the CEO (never
|
||||
stack a second one), nothing has shipped since the last spotlight
|
||||
@@ -742,7 +745,7 @@ class XEngine(BaseService):
|
||||
one boolean so ``open_feature_spotlight_exploration``'s own
|
||||
return-statement count stays under the xenon/PLR0911 budget — each
|
||||
sub-guard still logs its own skip reason."""
|
||||
if not (settings.x_engine_enabled and settings.x_feature_spotlight_enabled):
|
||||
if not await program_armed(self.session, "x_feature"):
|
||||
return False
|
||||
client = await self._client()
|
||||
if not client.configured:
|
||||
|
||||
@@ -209,6 +209,7 @@ class XPostService(BaseService):
|
||||
await self.session.commit()
|
||||
if task.source == X_FEATURE_SOURCE:
|
||||
await self._open_spotlight_video(task, body)
|
||||
await self._record_learn(task, "approved")
|
||||
return XPostExecuteResult(
|
||||
status="posted", tweet_id=result.tweet_id, detail=result.detail
|
||||
)
|
||||
@@ -253,6 +254,27 @@ class XPostService(BaseService):
|
||||
except Exception as exc:
|
||||
logger.warning("spotlight video draft failed (best-effort): %s", exc)
|
||||
|
||||
async def _record_learn(
|
||||
self, task: TaskTable, verdict: str, reason: str | None = None
|
||||
) -> None:
|
||||
"""Best-effort LEARN for the x_feature Board Program — never allowed
|
||||
to affect the already-decided approve/reject, mirrors
|
||||
``_open_spotlight_video``'s posture. ``item_ref`` is the feature slug
|
||||
(stamped on ``x_feature_ref`` at draft-materialization time), falling
|
||||
back to the task id when the marker is somehow missing."""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
ref = markers.get_x_feature_ref(task) or {}
|
||||
item_ref = str(ref.get("slug") or task.id)
|
||||
await get_board_program_engine(self.session).record_decision(
|
||||
"x_feature", item_ref, verdict, reason
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"x-post: LEARN record_decision failed (best-effort): %s", exc
|
||||
)
|
||||
|
||||
async def reject(self, task_id: UUID, reason: str) -> TaskTable | None:
|
||||
"""Record the CEO's reason, cancel the draft (never posted), and — for
|
||||
a non-blank reason — schedule a redraft of the same source once this
|
||||
@@ -305,8 +327,12 @@ class XPostService(BaseService):
|
||||
await self.session.flush()
|
||||
finally:
|
||||
await self._release_lock(lock_key, token)
|
||||
# Outside the lock, after the cancel is flushed: a non-blank reason
|
||||
# schedules the redraft. Never inline here (see _schedule_redraft).
|
||||
# Outside the lock, after the cancel is flushed: LEARN records the
|
||||
# rejection (x_feature source only — mirrors _post's approve-side
|
||||
# hook), then a non-blank reason schedules the redraft. Never inline
|
||||
# in the critical section above (see _schedule_redraft).
|
||||
if locked.source == X_FEATURE_SOURCE:
|
||||
await self._record_learn(locked, "rejected", reason)
|
||||
if reason.strip():
|
||||
self._schedule_redraft(task_id, reason)
|
||||
return locked
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Scenario: the generic Board Program loop end to end (Task 9).
|
||||
|
||||
Drives the REAL ``BoardProgramEngine`` (not a mock) against a REAL Postgres
|
||||
through the harness: arming the roadmap program via the new per-program
|
||||
settings-store key opens ONE held exploration task the delivery dispatcher's
|
||||
pending-claim filter skips (it's board-dispatched, not delivery work); a
|
||||
second tick dedups (no second task/cycle row); and approving a fake item
|
||||
through the real ``RoadmapService`` moves the LEARN ledger's counters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from roboco.foundation import identity as _foundation
|
||||
from tests.e2e_smoke.arcs import Company, seed_company, seed_project
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from tests.e2e_smoke.harness import E2EStack
|
||||
|
||||
ONE = 1
|
||||
ZERO = 0
|
||||
|
||||
|
||||
def _seed_system_and_po(stack: E2EStack) -> None:
|
||||
"""Seed ``system`` + ``product-owner`` at their FIXED foundation UUIDs.
|
||||
|
||||
``RoadmapEngine._originate`` stamps ``assigned_to``/``created_by`` from
|
||||
the static identity registry (not a DB lookup keyed by role), so those
|
||||
exact ids must exist as real agent rows for the FK to resolve —
|
||||
``seed_company``'s random ``uuid4()`` agents don't cover this (mirrors
|
||||
``test_feature_spotlight.py``'s ``_seed_system_and_secretary``)."""
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
|
||||
async def _run(session: AsyncSession) -> None:
|
||||
for agent_uuid, slug, role, team in (
|
||||
(_foundation.AGENTS["system"].uuid, "system", AgentRole.SYSTEM, None),
|
||||
(
|
||||
_foundation.AGENTS["product-owner"].uuid,
|
||||
"product-owner",
|
||||
AgentRole.PRODUCT_OWNER,
|
||||
Team.BOARD,
|
||||
),
|
||||
):
|
||||
if await session.get(AgentTable, agent_uuid) is not None:
|
||||
continue
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=agent_uuid,
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
team=team,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt=slug,
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
|
||||
stack.run_db(_run)
|
||||
|
||||
|
||||
def _arm_roadmap(stack: E2EStack, project_slug: str) -> None:
|
||||
"""Arm via the settings-store key ONLY — ``RoadmapEngine.run_cycle`` now
|
||||
routes through ``roboco.services.board_programs.program_armed``, the
|
||||
same resolver ``BoardProgramEngine.enabled`` consults, so the legacy
|
||||
``roadmap_engine_enabled`` flag stays False here on purpose: this is the
|
||||
end-to-end guard against the double-flag regression where the settings
|
||||
store alone used to be silently overridden by a False legacy flag."""
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.db.tables import SystemSettingTable
|
||||
|
||||
cfg.self_heal_project_slug = project_slug
|
||||
|
||||
async def _run(session: AsyncSession) -> None:
|
||||
session.add(
|
||||
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
|
||||
)
|
||||
|
||||
stack.run_db(_run)
|
||||
|
||||
|
||||
def _run_due_programs(stack: E2EStack) -> list[str]:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
async def _run(session: AsyncSession) -> list[str]:
|
||||
return await get_board_program_engine(session).run_due_programs()
|
||||
|
||||
result: list[str] = stack.run_db(_run)
|
||||
return result
|
||||
|
||||
|
||||
def _find_roadmap_task(stack: E2EStack) -> dict[str, Any]:
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.services.task import ROADMAP_SOURCE
|
||||
from sqlalchemy import select
|
||||
|
||||
async def _run(session: AsyncSession) -> dict[str, Any]:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(TaskTable).where(TaskTable.source == ROADMAP_SOURCE)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"count": len(rows),
|
||||
"rows": [
|
||||
{
|
||||
"id": r.id,
|
||||
"status": str(r.status),
|
||||
"assigned_to": r.assigned_to,
|
||||
"source": r.source,
|
||||
"confirmed_by_human": r.confirmed_by_human,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
|
||||
state: dict[str, Any] = stack.run_db(_run)
|
||||
return state
|
||||
|
||||
|
||||
def _cycle_counters(stack: E2EStack) -> dict[str, Any]:
|
||||
from roboco.db.tables import BoardProgramCycleTable
|
||||
from sqlalchemy import select
|
||||
|
||||
async def _run(session: AsyncSession) -> dict[str, Any]:
|
||||
row = (
|
||||
(
|
||||
await session.execute(
|
||||
select(BoardProgramCycleTable)
|
||||
.where(BoardProgramCycleTable.program_key == "roadmap")
|
||||
.order_by(BoardProgramCycleTable.opened_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
assert row is not None
|
||||
return {
|
||||
"items_proposed": row.items_proposed,
|
||||
"items_approved": row.items_approved,
|
||||
"items_rejected": row.items_rejected,
|
||||
}
|
||||
|
||||
state: dict[str, Any] = stack.run_db(_run)
|
||||
return state
|
||||
|
||||
|
||||
def _approve_fake_item(
|
||||
stack: E2EStack, task_id: Any, project_slug: str, company: Company
|
||||
) -> str:
|
||||
from roboco.db.tables import TaskTable
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.services.roadmap_service import get_roadmap_service
|
||||
from sqlalchemy import select
|
||||
|
||||
async def _run(session: AsyncSession) -> str:
|
||||
task = (
|
||||
await session.execute(select(TaskTable).where(TaskTable.id == task_id))
|
||||
).scalar_one()
|
||||
markers.set_roadmap_cycle(
|
||||
task,
|
||||
{
|
||||
"goal": "Close onboarding friction",
|
||||
"items": [
|
||||
{
|
||||
"id": "item-0",
|
||||
"title": "Streamline signup",
|
||||
"description": "Cut the signup form from 8 fields to 3",
|
||||
"acceptance_criteria": ["signup takes < 30s"],
|
||||
"project_slug": project_slug,
|
||||
"team": "backend",
|
||||
"priority": 2,
|
||||
"rationale": "signup drop-off is the top funnel leak",
|
||||
"status": "proposed",
|
||||
"reject_reason": None,
|
||||
"materialized_task_id": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
result = await get_roadmap_service(session).approve_item(
|
||||
task_id, "item-0", created_by=company.ceo_id
|
||||
)
|
||||
assert result is not None
|
||||
return result.status
|
||||
|
||||
status: str = stack.run_db(_run)
|
||||
return status
|
||||
|
||||
|
||||
def test_board_program_loop_originates_dedups_and_records(
|
||||
e2e_stack: E2EStack,
|
||||
) -> None:
|
||||
stack = e2e_stack
|
||||
company = seed_company(stack)
|
||||
_seed_system_and_po(stack)
|
||||
_project_id, project_slug = seed_project(stack, company)
|
||||
_arm_roadmap(stack, project_slug)
|
||||
|
||||
opened = _run_due_programs(stack)
|
||||
assert opened == ["roadmap"], opened
|
||||
|
||||
state = _find_roadmap_task(stack)
|
||||
assert state["count"] == ONE, state
|
||||
row = state["rows"][0]
|
||||
assert row["status"] == "pending"
|
||||
assert row["assigned_to"] == _foundation.AGENTS["product-owner"].uuid
|
||||
assert row["confirmed_by_human"] is False
|
||||
|
||||
# The dispatcher's own dev-work skip recognizes this exact task shape —
|
||||
# board_roadmap is board-dispatched (one-shot PO spawn), never handed to
|
||||
# the generic dev dispatch loop's give_me_work/claim path.
|
||||
from roboco.runtime.orchestrator import _is_non_dev_dispatch_source
|
||||
|
||||
assert _is_non_dev_dispatch_source({"source": row["source"]}) is True
|
||||
|
||||
# Second tick: the open cycle blocks re-origination — no second task.
|
||||
opened_again = _run_due_programs(stack)
|
||||
assert opened_again == [], opened_again
|
||||
state_after = _find_roadmap_task(stack)
|
||||
assert state_after["count"] == ONE, state_after
|
||||
|
||||
# Approve a fake item on the open cycle through the real RoadmapService —
|
||||
# the LEARN ledger's counters move.
|
||||
status = _approve_fake_item(stack, row["id"], project_slug, company)
|
||||
assert status == "approved"
|
||||
|
||||
counters = _cycle_counters(stack)
|
||||
assert counters["items_proposed"] == ONE
|
||||
assert counters["items_approved"] == ONE
|
||||
assert counters["items_rejected"] == ZERO
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""tests/unit/foundation/test_board_programs.py"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from roboco.config import Settings
|
||||
from roboco.foundation.policy.board_programs import (
|
||||
PROGRAMS,
|
||||
BoardProgram,
|
||||
TriggerKind,
|
||||
program_due,
|
||||
project_participates,
|
||||
validate_board_programs_field,
|
||||
)
|
||||
|
||||
|
||||
def test_x_feature_default_interval_matches_settings_field_default() -> None:
|
||||
"""Guards the registry cadence and the live due-check's cadence
|
||||
(``Settings.x_feature_spotlight_interval_seconds``) from drifting apart
|
||||
again — reads the pydantic field default, never a live env-configured
|
||||
instance, so this can't pass by accident in a differently-configured
|
||||
environment."""
|
||||
field_default = Settings.model_fields["x_feature_spotlight_interval_seconds"]
|
||||
assert PROGRAMS["x_feature"].default_interval_seconds == field_default.default
|
||||
|
||||
|
||||
def test_registry_carries_the_two_migrated_programs() -> None:
|
||||
assert set(PROGRAMS) == {"roadmap", "x_feature"}
|
||||
rm = PROGRAMS["roadmap"]
|
||||
assert rm.role == "product_owner"
|
||||
assert rm.source == "board_roadmap"
|
||||
assert rm.trigger is TriggerKind.CRON
|
||||
xf = PROGRAMS["x_feature"]
|
||||
assert xf.role == "head_marketing"
|
||||
assert xf.source == "x_feature_exploration"
|
||||
|
||||
|
||||
def test_program_due_cron_interval() -> None:
|
||||
now = datetime(2026, 7, 24, tzinfo=UTC)
|
||||
p = PROGRAMS["roadmap"]
|
||||
assert program_due(p, now=now, last_opened_at=None, interval_override=None)
|
||||
recent = now - timedelta(seconds=10)
|
||||
assert not program_due(p, now=now, last_opened_at=recent, interval_override=None)
|
||||
old = now - timedelta(seconds=p.default_interval_seconds + 1)
|
||||
assert program_due(p, now=now, last_opened_at=old, interval_override=None)
|
||||
|
||||
|
||||
def test_program_due_event_never_cron_fires() -> None:
|
||||
p = BoardProgram(
|
||||
key="k",
|
||||
role="auditor",
|
||||
trigger=TriggerKind.EVENT,
|
||||
source="s",
|
||||
default_interval_seconds=0,
|
||||
)
|
||||
assert not program_due(
|
||||
p,
|
||||
now=datetime(2026, 7, 24, tzinfo=UTC),
|
||||
last_opened_at=None,
|
||||
interval_override=None,
|
||||
)
|
||||
|
||||
|
||||
def test_program_due_interval_override_wins_over_default() -> None:
|
||||
now = datetime(2026, 7, 24, tzinfo=UTC)
|
||||
p = PROGRAMS["roadmap"]
|
||||
recent = now - timedelta(seconds=100)
|
||||
# Default interval (a week) would still block; a short override fires.
|
||||
assert program_due(p, now=now, last_opened_at=recent, interval_override=50)
|
||||
assert not program_due(p, now=now, last_opened_at=recent, interval_override=200)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 6b: per-project program scoping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_entries_default_to_org_scope() -> None:
|
||||
assert PROGRAMS["roadmap"].scope == "org"
|
||||
assert PROGRAMS["x_feature"].scope == "org"
|
||||
|
||||
|
||||
_PROJECT_PROGRAM = BoardProgram(
|
||||
key="pest_control",
|
||||
role="product_owner",
|
||||
trigger=TriggerKind.CRON,
|
||||
source="board_pest_control",
|
||||
default_interval_seconds=7 * 24 * 3600,
|
||||
scope="project",
|
||||
)
|
||||
_ORG_PROGRAM = PROGRAMS["roadmap"] # scope="org"
|
||||
|
||||
|
||||
def test_project_scoped_program_is_affirmative_opt_in() -> None:
|
||||
assert not project_participates(_PROJECT_PROGRAM, None)
|
||||
assert not project_participates(_PROJECT_PROGRAM, [])
|
||||
assert not project_participates(_PROJECT_PROGRAM, ["some_other_key"])
|
||||
assert project_participates(_PROJECT_PROGRAM, ["pest_control"])
|
||||
|
||||
|
||||
def test_org_scoped_program_is_default_eligible_opt_out() -> None:
|
||||
assert project_participates(_ORG_PROGRAM, None)
|
||||
assert project_participates(_ORG_PROGRAM, [])
|
||||
assert project_participates(_ORG_PROGRAM, ["some_other_key"])
|
||||
assert not project_participates(_ORG_PROGRAM, ["!roadmap"])
|
||||
|
||||
|
||||
def test_validate_board_programs_field_accepts_none() -> None:
|
||||
assert validate_board_programs_field(None) is None
|
||||
|
||||
|
||||
def test_validate_board_programs_field_accepts_known_org_exclusion() -> None:
|
||||
assert validate_board_programs_field(["!roadmap"]) == ["!roadmap"]
|
||||
|
||||
|
||||
def test_validate_board_programs_field_rejects_plain_key_on_org_scoped_program() -> (
|
||||
None
|
||||
):
|
||||
"""Both registered programs are org-scoped today, so a plain "roadmap"
|
||||
entry (the project-scoped opt-in form) is meaningless — org-scoped
|
||||
programs run against every project by default and are only ever
|
||||
excluded via '!key'. See test_validate_board_programs_field_allows_
|
||||
plain_project_scoped_key below for the positive case on a synthetic
|
||||
project-scoped program."""
|
||||
with pytest.raises(ValueError, match="meaningless"):
|
||||
validate_board_programs_field(["roadmap"])
|
||||
|
||||
|
||||
def test_validate_board_programs_field_rejects_unknown_key() -> None:
|
||||
with pytest.raises(ValueError, match="unknown board program key"):
|
||||
validate_board_programs_field(["not_a_real_program"])
|
||||
|
||||
|
||||
def test_validate_board_programs_field_rejects_unknown_excluded_key() -> None:
|
||||
with pytest.raises(ValueError, match="unknown board program key"):
|
||||
validate_board_programs_field(["!not_a_real_program"])
|
||||
|
||||
|
||||
def test_validate_board_programs_field_rejects_bang_on_project_scoped_key() -> None:
|
||||
registry = {"pest_control": _PROJECT_PROGRAM}
|
||||
with pytest.raises(ValueError, match="meaningless"):
|
||||
validate_board_programs_field(["!pest_control"], programs=registry)
|
||||
|
||||
|
||||
def test_validate_board_programs_field_allows_plain_project_scoped_key() -> None:
|
||||
registry = {"pest_control": _PROJECT_PROGRAM}
|
||||
assert validate_board_programs_field(["pest_control"], programs=registry) == [
|
||||
"pest_control"
|
||||
]
|
||||
@@ -62,6 +62,20 @@ def _valid_items(n: int) -> list[dict[str, Any]]:
|
||||
return [_valid_item(i) for i in range(n)]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _default_project_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Task 6b's exclusion check (``_reject_excluded_roadmap_project``) looks
|
||||
up ``project_slug`` on every propose_roadmap call. Every test predating
|
||||
that check builds its ``ContentActions`` with a bare ``MagicMock``
|
||||
session, so default the lookup to "unresolvable" (None -> not rejected,
|
||||
same as an unknown slug always behaved) instead of making every one of
|
||||
them mock a project service it isn't testing. Tests exercising the
|
||||
exclusion check override this target explicitly."""
|
||||
stub = MagicMock()
|
||||
stub.get_by_slug = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr("roboco.services.project.get_project_service", lambda _s: stub)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_roadmap_forbidden_for_non_po() -> None:
|
||||
env = await _actions("head_marketing").propose_roadmap(
|
||||
@@ -251,6 +265,67 @@ async def test_propose_roadmap_ignores_cycle_assigned_to_another_agent(
|
||||
assert env.error == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_roadmap_rejects_item_targeting_excluded_project(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Task 6b: an item targeting a project carrying '!roadmap' is refused
|
||||
at propose time, naming the excluded project — before the PO even
|
||||
finishes authoring the cycle, not just at the CEO's later approve."""
|
||||
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
|
||||
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
|
||||
agent_id = uuid4()
|
||||
cycle_task = _FakeTask(assigned_to=agent_id)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
|
||||
excluded_project = MagicMock(board_programs=["!roadmap"])
|
||||
project_svc = MagicMock()
|
||||
project_svc.get_by_slug = AsyncMock(return_value=excluded_project)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.project.get_project_service", lambda _s: project_svc
|
||||
)
|
||||
|
||||
bad = _valid_item(0)
|
||||
bad["project_slug"] = "excluded-proj"
|
||||
env = await _actions("product_owner").propose_roadmap(
|
||||
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
|
||||
)
|
||||
assert env.error == "invalid_state"
|
||||
assert "excluded-proj" in (env.message or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_roadmap_allows_unresolvable_project_slug_through(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An unknown project_slug is not this check's job — it surfaces
|
||||
downstream at approve/materialize time, unchanged from before Task 6b."""
|
||||
monkeypatch.setattr(cfg, "roadmap_min_items_per_cycle", 1)
|
||||
monkeypatch.setattr(cfg, "roadmap_max_items_per_cycle", 7)
|
||||
agent_id = uuid4()
|
||||
cycle_task = _FakeTask(assigned_to=agent_id)
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_open_roadmap_cycles = AsyncMock(return_value=[cycle_task])
|
||||
monkeypatch.setattr("roboco.services.task.get_task_service", lambda _s: task_svc)
|
||||
|
||||
project_svc = MagicMock()
|
||||
project_svc.get_by_slug = AsyncMock(return_value=None)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.project.get_project_service", lambda _s: project_svc
|
||||
)
|
||||
|
||||
actions = _actions("product_owner")
|
||||
actions.task.session.flush = AsyncMock()
|
||||
bad = _valid_item(0)
|
||||
bad["project_slug"] = "no-such-project"
|
||||
env = await actions.propose_roadmap(
|
||||
agent_id=agent_id, cycle_goal="Close onboarding friction", items=[bad]
|
||||
)
|
||||
assert env.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_propose_roadmap_ignores_already_authored_cycle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The generic Board Program orchestrator loop — replaces
|
||||
``_roadmap_engine_loop`` / ``_x_feature_spotlight_loop`` (see
|
||||
tests/unit/services/test_board_program_engine.py for the engine's own
|
||||
trigger/dedup/LEARN coverage; this file covers the orchestrator-loop shell
|
||||
only: interval computation + the sleep/tick/heartbeat wiring).
|
||||
|
||||
Unlike the two collapsed loops, ``_board_program_loop`` has no single static
|
||||
disablement gate — each program's enablement is checked per-tick inside
|
||||
``BoardProgramEngine``, DB-backed — so there is no "returns immediately when
|
||||
disabled" behavior to test here; that guarantee now lives in
|
||||
test_board_program_engine.py's disabled-program coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
TWO_TICKS = 2
|
||||
ONE_HOUR_SECONDS = 3600
|
||||
|
||||
|
||||
def _orch() -> Any:
|
||||
"""Bypass __init__ — the loop helper under test needs only the
|
||||
heartbeats dict and ``_running``."""
|
||||
o = AgentOrchestrator.__new__(AgentOrchestrator)
|
||||
o._loop_heartbeats = {}
|
||||
o._running = True
|
||||
return o
|
||||
|
||||
|
||||
def test_board_program_interval_is_shortest_registered_cadence_capped() -> None:
|
||||
orch = _orch()
|
||||
interval = orch._board_program_interval_seconds()
|
||||
# x_feature's 1-day default is the shortest of the two registered
|
||||
# programs, well above the 300s floor — capped at the 3600s ceiling.
|
||||
assert interval == ONE_HOUR_SECONDS
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_board_program_loop_one_tick_exception_does_not_crash_loop() -> None:
|
||||
"""A raising cycle is logged and the loop keeps ticking — mirrors every
|
||||
other engine loop's ``except Exception: logger.exception(...)`` shape."""
|
||||
orch = _orch()
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _cycle() -> None:
|
||||
calls["n"] += 1
|
||||
if calls["n"] >= TWO_TICKS:
|
||||
orch._running = False
|
||||
raise RuntimeError("boom")
|
||||
|
||||
orch._run_board_program_cycle = AsyncMock(side_effect=_cycle)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
await orch._board_program_loop()
|
||||
|
||||
assert calls["n"] == TWO_TICKS
|
||||
@@ -189,3 +189,41 @@ def test_feature_spotlight_prompt_brief_fallbacks_when_marker_missing() -> None:
|
||||
prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "nothing new since the last cycle" in prompt
|
||||
assert "(none)" in prompt
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_omits_prior_cycles_section_when_empty() -> None:
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_feature_spotlight_prompt(_feature_task())
|
||||
assert "## Prior cycles" not in prompt
|
||||
|
||||
|
||||
def test_feature_spotlight_prompt_renders_prior_cycles_when_given() -> None:
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_feature_spotlight_prompt(
|
||||
_feature_task(), "proposed 1, approved 0; rejected: org-memory — too soon"
|
||||
)
|
||||
assert "## Prior cycles" in prompt
|
||||
assert "proposed 1, approved 0; rejected: org-memory — too soon" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_dispatch_injects_prior_context_into_prompt() -> None:
|
||||
"""The dispatcher fetches LEARN context (best-effort) and threads it into
|
||||
the prompt builder — proving the wiring, not just the builder in
|
||||
isolation."""
|
||||
orch = _make_orch()
|
||||
task = _feature_task()
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_task_git_context", return_value=None),
|
||||
patch.object(
|
||||
orch,
|
||||
"_board_program_prior_context",
|
||||
AsyncMock(return_value="proposed 1, approved 1"),
|
||||
),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._dispatch_feature_spotlight_exploration(task)
|
||||
|
||||
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
|
||||
assert "proposed 1, approved 1" in prompt
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""The feature-spotlight orchestrator loop is fully dormant unless BOTH the X
|
||||
engine and the feature-spotlight sub-switch are enabled (both default off).
|
||||
|
||||
With either flag off, ``_x_feature_spotlight_loop`` must return immediately —
|
||||
no sleep, no HTTP, no DB, no Head-of-Marketing spawn — so a standard
|
||||
deployment (or one running only release posts / mention replies) behaves
|
||||
exactly as today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_returns_immediately_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
# Gated off -> returns at once. If the gate were missing it would sleep the
|
||||
# full interval and this wait_for would time out.
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_dormant_when_only_subswitch_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""x_engine_enabled on but the feature-spotlight sub-switch off: still
|
||||
dormant — the engine still runs release posts/mention replies via their
|
||||
own loops, unaffected."""
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", True)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_x_feature_spotlight_loop_dormant_when_only_engine_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The subswitch alone is not enough — x_engine_enabled must also be on."""
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", True)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
await asyncio.wait_for(
|
||||
AgentOrchestrator._x_feature_spotlight_loop(stub), timeout=1.0
|
||||
)
|
||||
@@ -23,7 +23,6 @@ from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
_CI_WATCH_INTERVAL = 0.01
|
||||
_VIDEO_RENDER_INTERVAL = 0.05
|
||||
_X_MENTIONS_INTERVAL = 0.04
|
||||
_ROADMAP_INTERVAL = 0.06
|
||||
|
||||
|
||||
def _orch() -> Any:
|
||||
@@ -199,23 +198,22 @@ async def test_x_mentions_loop_records_heartbeat(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roadmap_engine_loop_records_heartbeat(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The roadmap-engine loop records under its canonical name + interval —
|
||||
guards against copy-paste name drift on the heartbeat calls."""
|
||||
async def test_board_program_loop_records_heartbeat() -> None:
|
||||
"""The board-program loop (replaces roadmap-engine/x-feature-spotlight)
|
||||
records under its canonical name + interval — guards against copy-paste
|
||||
name drift on the heartbeat calls. See test_board_program_loop.py for
|
||||
the rest of this loop's coverage (interval computation, tick-error
|
||||
isolation)."""
|
||||
orch = _orch()
|
||||
monkeypatch.setattr(settings, "roadmap_engine_enabled", True)
|
||||
monkeypatch.setattr(settings, "roadmap_interval_seconds", 0.06)
|
||||
|
||||
async def _stop_after_cycle() -> None:
|
||||
orch._running = False
|
||||
|
||||
orch._run_roadmap_engine_cycle = AsyncMock(side_effect=_stop_after_cycle)
|
||||
orch._run_board_program_cycle = AsyncMock(side_effect=_stop_after_cycle)
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock()):
|
||||
await orch._roadmap_engine_loop()
|
||||
await orch._board_program_loop()
|
||||
|
||||
assert "roadmap_engine" in orch._loop_heartbeats
|
||||
_, interval = orch._loop_heartbeats["roadmap_engine"]
|
||||
assert interval == _ROADMAP_INTERVAL
|
||||
assert "board_program" in orch._loop_heartbeats
|
||||
_, interval = orch._loop_heartbeats["board_program"]
|
||||
assert interval == orch._board_program_interval_seconds()
|
||||
|
||||
@@ -153,3 +153,54 @@ def test_roadmap_prompt_names_solo_po_and_real_verbs() -> None:
|
||||
assert "Head of Marketing is not" in prompt
|
||||
assert "involved in this cycle" in prompt
|
||||
assert "do not" in prompt.lower()
|
||||
|
||||
|
||||
def test_roadmap_prompt_omits_prior_cycles_section_when_empty() -> None:
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_roadmap_prompt(_roadmap_task())
|
||||
assert "## Prior cycles" not in prompt
|
||||
|
||||
|
||||
def test_roadmap_prompt_renders_prior_cycles_when_given() -> None:
|
||||
orch = _make_orch()
|
||||
prompt = orch._build_roadmap_prompt(
|
||||
_roadmap_task(), "proposed 5, approved 3; rejected: item-2 — too risky"
|
||||
)
|
||||
assert "## Prior cycles" in prompt
|
||||
assert "proposed 5, approved 3; rejected: item-2 — too risky" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roadmap_dispatch_injects_prior_context_into_prompt() -> None:
|
||||
"""The dispatcher fetches LEARN context (best-effort) and threads it into
|
||||
the prompt builder — proving the wiring, not just the builder in
|
||||
isolation."""
|
||||
orch = _make_orch()
|
||||
task = _roadmap_task()
|
||||
with (
|
||||
patch.object(orch, "_is_agent_active", return_value=False),
|
||||
patch.object(orch, "_task_git_context", return_value=None),
|
||||
patch.object(
|
||||
orch,
|
||||
"_board_program_prior_context",
|
||||
AsyncMock(return_value="proposed 2, approved 1"),
|
||||
),
|
||||
patch.object(orch, "spawn_agent", new=AsyncMock()) as spawn,
|
||||
):
|
||||
await orch._dispatch_roadmap_exploration(task)
|
||||
|
||||
prompt = spawn.await_args_list[0].kwargs["initial_prompt"]
|
||||
assert "proposed 2, approved 1" in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_board_program_prior_context_survives_db_failure() -> None:
|
||||
"""A DB hiccup fetching prior context degrades to '' — never raises, so
|
||||
the caller (the dispatcher) never needs its own safety net around it."""
|
||||
orch = _make_orch()
|
||||
with patch(
|
||||
"roboco.services.board_programs.get_board_program_engine",
|
||||
side_effect=RuntimeError("db down"),
|
||||
):
|
||||
result = await orch._board_program_prior_context("roadmap")
|
||||
assert result == ""
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
"""The roadmap-engine orchestrator loop is fully dormant when disabled
|
||||
(default).
|
||||
|
||||
With ``roadmap_engine_enabled`` off, ``_roadmap_engine_loop`` must return
|
||||
immediately — no sleep, no HTTP, no DB, no Product-Owner spawn — so a
|
||||
standard deployment behaves exactly as today.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import types
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.runtime.orchestrator import AgentOrchestrator
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roadmap_engine_loop_returns_immediately_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
|
||||
stub = cast("AgentOrchestrator", types.SimpleNamespace(_running=True))
|
||||
# Gated off -> returns at once. If the gate were missing it would sleep the
|
||||
# full interval and this wait_for would time out.
|
||||
await asyncio.wait_for(AgentOrchestrator._roadmap_engine_loop(stub), timeout=1.0)
|
||||
@@ -0,0 +1,559 @@
|
||||
"""BoardProgramEngine: trigger/dedup/originate/LEARN over the registry.
|
||||
|
||||
Mirrors test_roadmap_engine.py's seeding + real-Postgres style, but swaps in
|
||||
fake originators (monkeypatched into board_programs._ORIGINATORS) so this
|
||||
suite tests the ENGINE's own dedup/cron/LEARN logic in isolation from
|
||||
RoadmapEngine/XEngine's own internal guards (covered by their own suites).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
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.foundation.policy.board_programs import PROGRAMS, BoardProgram, TriggerKind
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
Complexity,
|
||||
TaskNature,
|
||||
TaskType,
|
||||
Team,
|
||||
)
|
||||
from roboco.models.base import (
|
||||
TaskStatus as TS,
|
||||
)
|
||||
from roboco.services import board_programs as bp_module
|
||||
from roboco.services.board_programs import BoardProgramEngine
|
||||
from roboco.services.task import (
|
||||
ROADMAP_SOURCE,
|
||||
X_FEATURE_EXPLORATION_SOURCE,
|
||||
TaskCreateRequest,
|
||||
get_task_service,
|
||||
)
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable, Callable
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
SLUG = "roboco"
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def _purge_board_program_pollution(db_session: AsyncSession) -> None:
|
||||
"""Board Program state (settings-store overrides, ledger rows, open
|
||||
roadmap/x_feature exploration tasks) is shared, cross-test-persistent DB
|
||||
state — this module's own tests write it mid-test, and the write-route
|
||||
integration suite (``test_board_programs_api.py``'s run-now, which
|
||||
commits) can leave it behind too. Purge before every test in this file
|
||||
so a leftover row never reads back as a false "already open"/"already
|
||||
armed" state or collides on a settings-store primary key."""
|
||||
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()
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
):
|
||||
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()
|
||||
project = ProjectTable(
|
||||
name="RoboCo",
|
||||
slug=SLUG,
|
||||
git_url="https://github.com/x/roboco.git",
|
||||
default_branch="master",
|
||||
protected_branches=["master"],
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _make_exploration(
|
||||
session: AsyncSession, *, source: str, status: TS = TS.PENDING
|
||||
) -> TaskTable:
|
||||
project = (
|
||||
await session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
||||
).scalar_one()
|
||||
task = await get_task_service(session).create(
|
||||
TaskCreateRequest(
|
||||
title="exploration cycle",
|
||||
description="x",
|
||||
acceptance_criteria=["propose once"],
|
||||
team=Team.BOARD,
|
||||
assigned_to=PO_UUID,
|
||||
created_by=SYSTEM_UUID,
|
||||
task_type=TaskType.ADMINISTRATIVE,
|
||||
nature=TaskNature.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
project_id=cast("UUID", project.id),
|
||||
status=TS.PENDING,
|
||||
source=source,
|
||||
confirmed_by_human=False,
|
||||
)
|
||||
)
|
||||
if status != TS.PENDING:
|
||||
task.status = status
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def _fake_originator(
|
||||
holder: dict[str, TaskTable | None],
|
||||
) -> Callable[[AsyncSession], Awaitable[TaskTable | None]]:
|
||||
async def _originate(_session: AsyncSession) -> TaskTable | None:
|
||||
return holder["task"]
|
||||
|
||||
return _originate
|
||||
|
||||
|
||||
def _patch_roadmap_originator(
|
||||
monkeypatch: pytest.MonkeyPatch, holder: dict[str, TaskTable | None]
|
||||
) -> None:
|
||||
monkeypatch.setitem(bp_module._ORIGINATORS, "roadmap", _fake_originator(holder))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_program_never_originates(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
|
||||
holder: dict[str, TaskTable | None] = {"task": None}
|
||||
_patch_roadmap_originator(monkeypatch, holder)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert "roadmap" not in opened
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dormant_with_no_settings_store_rows_and_legacy_flags_off(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No settings-store overrides + both legacy boot flags False: a tick
|
||||
originates nothing and writes ZERO ledger rows — the guarantee the
|
||||
deleted per-engine dormant-loop tests covered, now at the engine layer
|
||||
every program's arming decision routes through."""
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "x_feature_spotlight_enabled", False)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert opened == []
|
||||
|
||||
rows = (await db_session.execute(select(BoardProgramCycleTable))).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_cycle_blocks_reorigination(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
|
||||
task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=task.id,
|
||||
opened_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
holder: dict[str, TaskTable | None] = {"task": None}
|
||||
_patch_roadmap_originator(monkeypatch, holder)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert "roadmap" not in opened
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_due_program_originates_and_opens_cycle_row(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
|
||||
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
||||
holder: dict[str, TaskTable | None] = {"task": new_task}
|
||||
_patch_roadmap_originator(monkeypatch, holder)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert opened == ["roadmap"]
|
||||
|
||||
rows = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.program_key == "roadmap"
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == ONE
|
||||
assert rows[0].exploration_task_id == new_task.id
|
||||
assert rows[0].closed_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closed_cycle_past_interval_allows_reorigination(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
|
||||
monkeypatch.setattr(cfg, "roadmap_interval_seconds", 300)
|
||||
old_task = await _make_exploration(
|
||||
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
|
||||
)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=old_task.id,
|
||||
opened_at=datetime.now(UTC) - timedelta(seconds=301),
|
||||
closed_at=datetime.now(UTC) - timedelta(seconds=200),
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
||||
holder: dict[str, TaskTable | None] = {"task": new_task}
|
||||
_patch_roadmap_originator(monkeypatch, holder)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert opened == ["roadmap"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_closes_open_row_once_task_goes_terminal(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A stale open row whose exploration task already went terminal is
|
||||
reconciled (auto-closed) rather than permanently blocking dedup."""
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", True)
|
||||
monkeypatch.setattr(cfg, "roadmap_interval_seconds", 1)
|
||||
stale_task = await _make_exploration(
|
||||
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
|
||||
)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=stale_task.id,
|
||||
opened_at=datetime.now(UTC) - timedelta(seconds=5),
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
new_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
||||
holder: dict[str, TaskTable | None] = {"task": new_task}
|
||||
_patch_roadmap_originator(monkeypatch, holder)
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert opened == ["roadmap"]
|
||||
|
||||
rows = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable)
|
||||
.where(BoardProgramCycleTable.program_key == "roadmap")
|
||||
.order_by(BoardProgramCycleTable.opened_at)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == TWO
|
||||
assert rows[0].closed_at is not None # reconciled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_decision_bumps_counters_and_closes_when_done(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
task = await _make_exploration(
|
||||
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
|
||||
)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=task.id,
|
||||
opened_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
engine = BoardProgramEngine(db_session)
|
||||
await engine.record_decision("roadmap", "item-1", "approved")
|
||||
await engine.record_decision("roadmap", "item-2", "rejected", reason="not now")
|
||||
|
||||
row = await engine._latest_cycle("roadmap")
|
||||
assert row is not None
|
||||
assert row.items_proposed == TWO
|
||||
assert row.items_approved == 1
|
||||
assert row.items_rejected == 1
|
||||
assert row.closed_at is not None # exploration task was already terminal
|
||||
assert {
|
||||
"item_ref": "item-1",
|
||||
"verdict": "approved",
|
||||
"reason": None,
|
||||
} in row.decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_decision_targets_named_exploration_over_most_recent(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Two cycle rows exist for "roadmap": the FIRST auto-closed via a
|
||||
terminal exploration task with items still undecided (the admin-cancel
|
||||
edge), the SECOND opened after it and is the most-recent row. A decision
|
||||
carrying the FIRST task's id must land on the FIRST row, not silently
|
||||
fall through to the most-recent-cycle fallback."""
|
||||
await _seed(db_session)
|
||||
first_task = await _make_exploration(
|
||||
db_session, source=ROADMAP_SOURCE, status=TS.CANCELLED
|
||||
)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=first_task.id,
|
||||
opened_at=datetime.now(UTC) - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
second_task = await _make_exploration(db_session, source=ROADMAP_SOURCE)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=second_task.id,
|
||||
opened_at=datetime.now(UTC) - timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
engine = BoardProgramEngine(db_session)
|
||||
await engine.record_decision(
|
||||
"roadmap",
|
||||
"item-1",
|
||||
"approved",
|
||||
exploration_task_id=cast("UUID", first_task.id),
|
||||
)
|
||||
|
||||
first_row = await engine._cycle_for_exploration(
|
||||
"roadmap", cast("UUID", first_task.id)
|
||||
)
|
||||
second_row = await engine._cycle_for_exploration(
|
||||
"roadmap", cast("UUID", second_task.id)
|
||||
)
|
||||
assert first_row is not None
|
||||
assert second_row is not None
|
||||
assert first_row.items_proposed == ONE
|
||||
assert first_row.items_approved == ONE
|
||||
assert {
|
||||
"item_ref": "item-1",
|
||||
"verdict": "approved",
|
||||
"reason": None,
|
||||
} in first_row.decisions
|
||||
assert first_row.closed_at is not None # reconciled: task was terminal
|
||||
assert second_row.items_proposed == 0
|
||||
assert second_row.decisions == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prior_cycle_context_renders_rejections_with_reasons(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
assert await BoardProgramEngine(db_session).prior_cycle_context("roadmap") == ""
|
||||
|
||||
task = await _make_exploration(
|
||||
db_session, source=ROADMAP_SOURCE, status=TS.COMPLETED
|
||||
)
|
||||
db_session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="roadmap",
|
||||
exploration_task_id=task.id,
|
||||
opened_at=datetime.now(UTC),
|
||||
closed_at=datetime.now(UTC),
|
||||
items_proposed=2,
|
||||
items_approved=1,
|
||||
items_rejected=1,
|
||||
decisions=[
|
||||
{"item_ref": "item-1", "verdict": "approved", "reason": None},
|
||||
{"item_ref": "item-2", "verdict": "rejected", "reason": "too risky"},
|
||||
],
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
context = await BoardProgramEngine(db_session).prior_cycle_context("roadmap")
|
||||
assert "proposed 2, approved 1" in context
|
||||
assert "item-2 — too risky" in context
|
||||
|
||||
|
||||
def test_originators_cover_exactly_the_registry() -> None:
|
||||
assert set(bp_module._ORIGINATORS) == set(PROGRAMS)
|
||||
|
||||
|
||||
def test_program_sources_match_service_layer_constants() -> None:
|
||||
assert PROGRAMS["roadmap"].source == ROADMAP_SOURCE
|
||||
assert PROGRAMS["x_feature"].source == X_FEATURE_EXPLORATION_SOURCE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 6b: per-project program scoping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PEST_CONTROL = BoardProgram(
|
||||
key="pest_control",
|
||||
role="product_owner",
|
||||
trigger=TriggerKind.CRON,
|
||||
source="board_pest_control",
|
||||
default_interval_seconds=1,
|
||||
scope="project",
|
||||
)
|
||||
|
||||
|
||||
def _arm_setting(session: AsyncSession, key: str) -> None:
|
||||
"""Bypass ``SettingsService.set``'s key allowlist (a project-scoped test
|
||||
program is never a real writable key) and write the raw row directly —
|
||||
``get_bool`` only reads it, it never validates."""
|
||||
session.add(SystemSettingTable(key=key, value="true"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opted_in_projects_filters_by_project_participates(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
opted_in = ProjectTable(
|
||||
name="Opted In",
|
||||
slug="opted-in-proj",
|
||||
git_url="https://github.com/x/opted-in.git",
|
||||
default_branch="master",
|
||||
protected_branches=["master"],
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
is_active=True,
|
||||
board_programs=["pest_control"],
|
||||
)
|
||||
db_session.add(opted_in)
|
||||
await db_session.flush()
|
||||
|
||||
engine = BoardProgramEngine(db_session)
|
||||
projects = await engine.opted_in_projects(_PEST_CONTROL)
|
||||
# SLUG ("roboco", seeded by _seed) never opted in — only the new project.
|
||||
assert {p.slug for p in projects} == {"opted-in-proj"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_due_programs_skips_project_scoped_program_with_no_opt_in(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
|
||||
_arm_setting(db_session, "board_program.pest_control.enabled")
|
||||
holder: dict[str, TaskTable | None] = {"task": None}
|
||||
monkeypatch.setitem(
|
||||
bp_module._ORIGINATORS, "pest_control", _fake_originator(holder)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert "pest_control" not in opened
|
||||
|
||||
rows = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.program_key == "pest_control"
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_open_program_cycle_returns_none_with_no_project_opted_in(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
|
||||
_arm_setting(db_session, "board_program.pest_control.enabled")
|
||||
await db_session.flush()
|
||||
|
||||
engine = BoardProgramEngine(db_session)
|
||||
assert await engine.open_program_cycle("pest_control") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_due_programs_originates_project_scoped_program_with_opt_in(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
project = (
|
||||
await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG))
|
||||
).scalar_one()
|
||||
project.board_programs = ["pest_control"]
|
||||
monkeypatch.setitem(bp_module.PROGRAMS, "pest_control", _PEST_CONTROL)
|
||||
_arm_setting(db_session, "board_program.pest_control.enabled")
|
||||
new_task = await _make_exploration(db_session, source="board_pest_control")
|
||||
holder: dict[str, TaskTable | None] = {"task": new_task}
|
||||
monkeypatch.setitem(
|
||||
bp_module._ORIGINATORS, "pest_control", _fake_originator(holder)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
engine = BoardProgramEngine(db_session)
|
||||
opened = await engine.run_due_programs()
|
||||
assert opened == ["pest_control"]
|
||||
@@ -13,13 +13,25 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
BoardProgramCycleTable,
|
||||
ProjectTable,
|
||||
SystemSettingTable,
|
||||
TaskTable,
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models.base import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import TaskStatus as TS
|
||||
from roboco.services.roadmap_engine import RoadmapEngine
|
||||
from roboco.services.task import ROADMAP_SOURCE, get_task_service
|
||||
from roboco.services.task import (
|
||||
ROADMAP_SOURCE,
|
||||
X_FEATURE_EXPLORATION_SOURCE,
|
||||
get_task_service,
|
||||
)
|
||||
from sqlalchemy import delete, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -30,6 +42,28 @@ SLUG = "roboco"
|
||||
ONE = 1
|
||||
|
||||
|
||||
@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 tests, or the write-route ``test_board_programs_api.py``
|
||||
run-now test) can leave behind. 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()
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
@@ -116,6 +150,38 @@ async def test_dedupe_one_open_cycle(
|
||||
assert len(await get_task_service(db_session).list_open_roadmap_cycles()) == ONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_store_true_overrides_legacy_false(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The double-flag regression this guards: a settings-store True must
|
||||
win over a False legacy flag, not be silently overridden by it."""
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "roadmap_engine_enabled", False)
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
|
||||
db_session.add(
|
||||
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
|
||||
)
|
||||
await db_session.flush()
|
||||
engine = RoadmapEngine(db_session)
|
||||
assert await engine.run_cycle() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_store_false_overrides_legacy_true(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch)
|
||||
db_session.add(
|
||||
SystemSettingTable(key="board_program.roadmap.enabled", value="false")
|
||||
)
|
||||
await db_session.flush()
|
||||
engine = RoadmapEngine(db_session)
|
||||
assert await engine.run_cycle() is None
|
||||
assert await get_task_service(db_session).list_open_roadmap_cycles() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_project_no_cycle(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -7,11 +7,20 @@ 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
|
||||
from roboco.db.tables import AgentTable, AuditLogTable, ProjectTable, TaskTable
|
||||
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 (
|
||||
@@ -23,9 +32,14 @@ from roboco.models.base import (
|
||||
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
|
||||
from sqlalchemy import select
|
||||
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
|
||||
@@ -39,6 +53,30 @@ 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}",
|
||||
@@ -260,6 +298,25 @@ async def test_approve_unknown_project_slug_is_invalid_state(
|
||||
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)
|
||||
@@ -321,3 +378,87 @@ async def test_maybe_complete_cycle_emits_audit(db_session: AsyncSession) -> Non
|
||||
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"
|
||||
|
||||
@@ -2,12 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
BoardProgramCycleTable,
|
||||
ProjectTable,
|
||||
SystemSettingTable,
|
||||
TaskTable,
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models.base import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import TaskStatus as TS
|
||||
from roboco.services import strategy_engine as se_module
|
||||
from roboco.services.strategy_engine import StrategyEngine
|
||||
from roboco.services.task import (
|
||||
ROADMAP_SOURCE,
|
||||
X_FEATURE_EXPLORATION_SOURCE,
|
||||
get_task_service,
|
||||
)
|
||||
from sqlalchemy import delete, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_GOALS_WITH_DIRECTION: dict[str, Any] = {
|
||||
"north_star": "Win the market",
|
||||
@@ -23,6 +43,30 @@ _GOALS_EMPTY: dict[str, Any] = {
|
||||
}
|
||||
|
||||
|
||||
@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 idle-trigger tests, or the write-route
|
||||
``test_board_programs_api.py`` run-now test) can leave behind — this
|
||||
module's idle-trigger tests need the roadmap dedup gate genuinely open.
|
||||
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 _engine(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
@@ -109,3 +153,137 @@ async def test_run_cycle_enabled_no_observations_no_notify(
|
||||
|
||||
assert await eng.run_cycle() == []
|
||||
notifier.send_ack_notification.assert_not_awaited()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Task 6: idle -> roadmap Board Program trigger (real DB — BoardProgramEngine
|
||||
# dedup is what makes the second tick a no-op, so a fully-mocked session
|
||||
# can't exercise it; see test_board_program_engine.py for the engine's own
|
||||
# isolated trigger/dedup coverage).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
SLUG = "roboco"
|
||||
ONE = 1
|
||||
|
||||
|
||||
async def _seed_roadmap_fixture(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
):
|
||||
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()
|
||||
session.add(
|
||||
ProjectTable(
|
||||
name="RoboCo",
|
||||
slug=SLUG,
|
||||
git_url="https://github.com/x/roboco.git",
|
||||
default_branch="master",
|
||||
protected_branches=["master"],
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
def _mock_idle_assessment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
task_svc = MagicMock()
|
||||
task_svc.list_in_progress_or_claimed = AsyncMock(return_value=[])
|
||||
task_svc.list_long_running_blocked = AsyncMock(return_value=[])
|
||||
monkeypatch.setattr(se_module, "get_task_service", lambda _s: task_svc)
|
||||
goals_svc = MagicMock()
|
||||
goals_svc.get = AsyncMock(return_value=_GOALS_WITH_DIRECTION)
|
||||
monkeypatch.setattr(se_module, "get_company_goals_service", lambda _s: goals_svc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_triggers_roadmap_cycle(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roadmap_fixture(db_session)
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
|
||||
monkeypatch.setattr(se_module.settings, "roadmap_engine_enabled", True)
|
||||
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
|
||||
_mock_idle_assessment(monkeypatch)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
|
||||
|
||||
eng = StrategyEngine(db_session)
|
||||
await eng.run_cycle()
|
||||
|
||||
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
|
||||
assert len(open_cycles) == ONE
|
||||
body = notifier.send_ack_notification.call_args.kwargs["body"]
|
||||
assert "roadmap exploration cycle was opened" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_triggers_roadmap_cycle_armed_via_settings_store_only(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The roadmap program armed ONLY through the settings-store key (the
|
||||
legacy ``roadmap_engine_enabled`` flag left at its False default) still
|
||||
reaches origination through the full chain: strategy engine ->
|
||||
``BoardProgramEngine.open_program_cycle`` -> ``program_armed``."""
|
||||
await _seed_roadmap_fixture(db_session)
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
|
||||
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
|
||||
db_session.add(
|
||||
SystemSettingTable(key="board_program.roadmap.enabled", value="true")
|
||||
)
|
||||
await db_session.flush()
|
||||
_mock_idle_assessment(monkeypatch)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
|
||||
|
||||
eng = StrategyEngine(db_session)
|
||||
await eng.run_cycle()
|
||||
|
||||
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
|
||||
assert len(open_cycles) == ONE
|
||||
body = notifier.send_ack_notification.call_args.kwargs["body"]
|
||||
assert "roadmap exploration cycle was opened" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_second_tick_is_a_dedup_noop(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roadmap_fixture(db_session)
|
||||
monkeypatch.setattr(se_module.settings, "strategy_engine_enabled", True)
|
||||
monkeypatch.setattr(se_module.settings, "roadmap_engine_enabled", True)
|
||||
monkeypatch.setattr(se_module.settings, "self_heal_project_slug", SLUG)
|
||||
_mock_idle_assessment(monkeypatch)
|
||||
notifier = MagicMock()
|
||||
notifier.send_ack_notification = AsyncMock()
|
||||
monkeypatch.setattr(se_module, "NotificationService", lambda: notifier)
|
||||
|
||||
eng = StrategyEngine(db_session)
|
||||
await eng.run_cycle()
|
||||
await eng.run_cycle()
|
||||
|
||||
open_cycles = await get_task_service(db_session).list_open_roadmap_cycles()
|
||||
assert len(open_cycles) == ONE
|
||||
second_body = notifier.send_ack_notification.call_args.kwargs["body"]
|
||||
assert "already open" in second_body
|
||||
|
||||
@@ -21,6 +21,7 @@ from roboco.db.tables import (
|
||||
AgentTable,
|
||||
NotificationTable,
|
||||
ProjectTable,
|
||||
SystemSettingTable,
|
||||
TaskTable,
|
||||
XSeenFeatureTable,
|
||||
XSeenMentionTable,
|
||||
@@ -749,6 +750,39 @@ async def test_feature_spotlight_subswitch_off_creates_no_exploration(
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_settings_store_true_overrides_legacy_false(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The double-flag regression this guards: a settings-store True must
|
||||
win over a False legacy flag pair, not be silently overridden by it."""
|
||||
await _seed(db_session)
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", SLUG)
|
||||
db_session.add(
|
||||
SystemSettingTable(key="board_program.x_feature.enabled", value="true")
|
||||
)
|
||||
await db_session.flush()
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_settings_store_false_overrides_legacy_true(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed(db_session)
|
||||
_enable(monkeypatch, x_feature_spotlight_enabled=True)
|
||||
db_session.add(
|
||||
SystemSettingTable(key="board_program.x_feature.enabled", value="false")
|
||||
)
|
||||
await db_session.flush()
|
||||
engine = x_engine_module.XEngine(db_session, client=_FakeClient())
|
||||
task = await engine.open_feature_spotlight_exploration()
|
||||
assert task is None
|
||||
assert await get_task_service(db_session).list_open_feature_explorations() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_spotlight_no_credentials_creates_no_exploration(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@@ -10,13 +10,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings as cfg
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||
from roboco.db.tables import AgentTable, BoardProgramCycleTable, ProjectTable, TaskTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import (
|
||||
@@ -28,6 +29,7 @@ from roboco.models.base import (
|
||||
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 import x_engine as x_engine_module
|
||||
from roboco.services.company_goals import get_company_goals_service
|
||||
from roboco.services.task import (
|
||||
@@ -44,7 +46,7 @@ from roboco.services.x_post_service import (
|
||||
XPostService,
|
||||
get_x_post_service,
|
||||
)
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
@@ -662,6 +664,136 @@ async def test_approve_does_not_flush_edited_body_before_lock(
|
||||
assert markers.get_x_draft_body(task) == original_body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# LEARN wiring (Task 5): approve/reject of an X_FEATURE_SOURCE draft best-
|
||||
# effort records onto the open board_program_cycles row for "x_feature" —
|
||||
# other X sources (x_post/x_reply) are not board-program-backed and must
|
||||
# never record. 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="x_feature",
|
||||
exploration_task_id=task.id,
|
||||
opened_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _cycle_row_for_task(
|
||||
session: AsyncSession, task_id: UUID
|
||||
) -> BoardProgramCycleTable:
|
||||
"""The board_program_cycles row THIS task's approve/reject decided —
|
||||
scoped by exploration_task_id rather than a bare program_key filter, since
|
||||
``_post()``'s real ``session.commit()`` durably leaks rows from earlier
|
||||
tests into this file's shared session-scoped test DB (documented above
|
||||
`_delete_tasks`); a global program_key query would collide across tests."""
|
||||
return (
|
||||
await session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.exploration_task_id == task_id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_feature_spotlight_records_learn_decision(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
task = await _seed_feature_draft(db_session)
|
||||
await _seed_cycle_ledger_row(db_session, task)
|
||||
client = _StubClient()
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
await _svc(db_session).approve(_id(task))
|
||||
|
||||
row = await _cycle_row_for_task(db_session, _id(task))
|
||||
assert row.items_approved == ONE
|
||||
assert {
|
||||
"item_ref": _FEATURE_SLUG,
|
||||
"verdict": "approved",
|
||||
"reason": None,
|
||||
} in row.decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_feature_spotlight_records_learn_decision_with_reason(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
task = await _seed_feature_draft(db_session)
|
||||
await _seed_cycle_ledger_row(db_session, task)
|
||||
with _lock_free():
|
||||
await _svc(db_session).reject(_id(task), "not on-brand")
|
||||
|
||||
row = await _cycle_row_for_task(db_session, _id(task))
|
||||
assert row.items_rejected == ONE
|
||||
assert {
|
||||
"item_ref": _FEATURE_SLUG,
|
||||
"verdict": "rejected",
|
||||
"reason": "not on-brand",
|
||||
} in row.decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_plain_x_post_does_not_record_learn(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""x_post/x_reply drafts are not board-program-backed — approving one
|
||||
must never touch the board_program_cycles ledger."""
|
||||
task = await _seed_draft(db_session, source=X_POST_SOURCE)
|
||||
client = _StubClient()
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
await _svc(db_session).approve(_id(task))
|
||||
|
||||
rows = (
|
||||
(
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.exploration_task_id == task.id
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert rows == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_feature_spotlight_survives_learn_recording_failure(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A record_decision blow-up must never break the already-succeeded post."""
|
||||
task = await _seed_feature_draft(db_session)
|
||||
await _seed_cycle_ledger_row(db_session, task)
|
||||
client = _StubClient()
|
||||
|
||||
async def _boom(_self: object, *_args: object, **_kwargs: object) -> None:
|
||||
raise RuntimeError("learn boom")
|
||||
|
||||
monkeypatch.setattr(bp_module.BoardProgramEngine, "record_decision", _boom)
|
||||
with (
|
||||
patch("roboco.services.x_post_service.build_x_client", return_value=client),
|
||||
patch.object(XPostService, "_acquire_lock", AsyncMock(return_value="tok")),
|
||||
patch.object(XPostService, "_release_lock", AsyncMock(return_value=None)),
|
||||
):
|
||||
result = await _svc(db_session).approve(_id(task))
|
||||
assert result is not None
|
||||
assert result.status == "posted"
|
||||
|
||||
|
||||
async def _fresh_session(url: str) -> tuple[AsyncSession, AsyncEngine]:
|
||||
"""A session on a brand-new engine/connection (caller disposes)."""
|
||||
engine = create_async_engine(url, future=True)
|
||||
|
||||
Reference in New Issue
Block a user