mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(board): materialize program items as Main-PM roots, make reports actionable (#711)
Two coupled gaps in the Board Program output path. Approved items were created unowned and in BACKLOG. Nothing dispatches BACKLOG, and once activated a cell PM claimed the parentless task as a root, where _cell_pm_complete resolves its merge target through resolve_parent_branch — which for a parentless task falls through to the project head rung. The result was a cell branch merging straight into the trunk, bypassing the Main-PM root, the root->master PR and the CEO gate (live: PRs #703 and #704 both targeted slave directly). All eight materializers now create a PENDING, main-pm-assigned root with team=Team.MAIN_PM, matching what approve_and_start does for an intake draft. The team is load-bearing, not cosmetic: _next_hint_pr_fail, _deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer label all key on it, and a cell-teamed root drops the 'do NOT re-submit the root' steer that exists because of PR #138's infinite pr_fail loop. The item's own cell survives as a delegation hint in the description, which is what the Main PM's briefing renders. Periscope, Sentinel and Coroner produced artifacts with no way to act on them — three panel surfaces carried explicit 'no approve/reject UI' comments while each item already held a machine-readable suggested action. They now have per-item approve and dismiss, modelled on the roadmap queue: idempotent per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check still fires, and every decision recorded through record_decision so it reaches the next cycle's prompt. Approving materializes through the same corrected Main-PM-owned path. Target project resolves to each engine's own existing anchor — RoboCo's project for Periscope and Sentinel, the incident's project for Coroner — and fails with a clean invalid_state naming what is unresolvable rather than guessing at a repo. Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -1,15 +1,26 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Postmortem } from "@/lib/api/coroner";
|
||||
|
||||
const { listPostmortems } = vi.hoisted(() => ({
|
||||
const { listPostmortems, approveProcessChange, rejectProcessChange } =
|
||||
vi.hoisted(() => ({
|
||||
listPostmortems: vi.fn(async () => [] as Postmortem[]),
|
||||
}));
|
||||
approveProcessChange: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectProcessChange: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
coronerApi: { listPostmortems },
|
||||
coronerApi: { listPostmortems, approveProcessChange, rejectProcessChange },
|
||||
}));
|
||||
|
||||
import { CoronerPostmortemsCard } from "../coroner-postmortems-card";
|
||||
@@ -21,7 +32,8 @@ function withQueryClient(ui: ReactNode) {
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const SAMPLE_POSTMORTEM: Postmortem = {
|
||||
function buildPostmortem(overrides: Partial<Postmortem> = {}): Postmortem {
|
||||
return {
|
||||
task_id: "pm-1",
|
||||
title: "Coroner postmortem",
|
||||
completed_at: "2026-07-24T10:00:00Z",
|
||||
@@ -34,11 +46,18 @@ const SAMPLE_POSTMORTEM: Postmortem = {
|
||||
process_change_kind: "conventions_rule",
|
||||
process_change_description: "Add a venv-freshness check to make quality.",
|
||||
playbook_id: null,
|
||||
};
|
||||
process_change_status: "proposed",
|
||||
process_change_reject_reason: null,
|
||||
process_change_materialized_task_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const SAMPLE_POSTMORTEM: Postmortem = buildPostmortem();
|
||||
|
||||
describe("CoronerPostmortemsCard", () => {
|
||||
beforeEach(() => {
|
||||
listPostmortems.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -61,15 +80,11 @@ describe("CoronerPostmortemsCard", () => {
|
||||
expect(screen.getByText("bounced 3+ times")).toBeInTheDocument();
|
||||
expect(screen.getByText("awaiting_qa")).toBeInTheDocument();
|
||||
expect(screen.getByText("conventions_rule")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/stale venv symptom/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/stale venv symptom/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/never verified the venv's dev extras/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/add a venv-freshness check/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/add a venv-freshness check/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an offline state and retries on error", async () => {
|
||||
@@ -82,4 +97,70 @@ describe("CoronerPostmortemsCard", () => {
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
// Defect 2 fix: a proposed process change now gets its own approve/dismiss
|
||||
// action.
|
||||
it("renders approve/dismiss controls for a proposed process change", async () => {
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once the process change already drafted a playbook", async () => {
|
||||
listPostmortems.mockResolvedValueOnce([
|
||||
buildPostmortem({
|
||||
process_change_kind: "playbook",
|
||||
process_change_status: "not_applicable",
|
||||
}),
|
||||
]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Drafted as playbook")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves the process change via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveProcessChange).toHaveBeenCalledWith("pm-1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses the process change with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
|
||||
render(withQueryClient(<CoronerPostmortemsCard />));
|
||||
|
||||
await screen.findByText("Fix worktree venv rot");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Reason"), "one-off incident");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectProcessChange).toHaveBeenCalledWith(
|
||||
"pm-1",
|
||||
"one-off incident",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { MarketBrief } from "@/lib/api/periscope";
|
||||
@@ -20,6 +21,7 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
|
||||
claim: "Competitor X launched an autonomous PR-review agent",
|
||||
source_url: "https://example.com/competitor-x-launch",
|
||||
relevance: "Directly overlaps our pr_reviewer role",
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
threats: ["Feature parity gap on PR review"],
|
||||
@@ -29,17 +31,28 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
|
||||
};
|
||||
}
|
||||
|
||||
const { listBriefs } = vi.hoisted(() => ({
|
||||
const { listBriefs, approveFinding, rejectFinding } = vi.hoisted(() => ({
|
||||
listBriefs: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveListRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
approveFinding: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
finding_id: "finding-0",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectFinding: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
finding_id: "finding-0",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
periscopeApi: { listBriefs },
|
||||
periscopeApi: { listBriefs, approveFinding, rejectFinding },
|
||||
}));
|
||||
|
||||
import { MarketBriefsTab } from "../market-briefs-tab";
|
||||
@@ -90,28 +103,94 @@ describe("MarketBriefsTab", () => {
|
||||
"href",
|
||||
"https://example.com/competitor-x-launch",
|
||||
);
|
||||
expect(screen.getByText("Feature parity gap on PR review")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Feature parity gap on PR review"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Lean into our findings-ledger differentiator"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Emphasize the structured findings ledger in messaging",
|
||||
),
|
||||
screen.getByText("Emphasize the structured findings ledger in messaging"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no approve/reject controls — a report has no queue action", async () => {
|
||||
// Defect 2 fix: a proposed finding now gets its own approve/dismiss action
|
||||
// even though the brief itself stays a read-only report — was "renders no
|
||||
// approve/reject controls".
|
||||
it("renders per-finding approve/dismiss controls for a proposed finding", async () => {
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once a finding is approved", async () => {
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([
|
||||
buildBrief({
|
||||
findings: [
|
||||
{
|
||||
id: "finding-0",
|
||||
claim: "Competitor X launched an autonomous PR-review agent",
|
||||
source_url: "https://example.com/competitor-x-launch",
|
||||
relevance: "Directly overlaps our pr_reviewer role",
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /reject/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves a finding via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveFinding).toHaveBeenCalledWith("brief-1", "finding-0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses a finding with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<MarketBriefsTab />));
|
||||
resolveListRef.current?.([buildBrief()]);
|
||||
|
||||
await screen.findByText("A rival tool shipped agentic PR review");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(
|
||||
screen.getByLabelText("Reason"),
|
||||
"not actionable this quarter",
|
||||
);
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectFinding).toHaveBeenCalledWith(
|
||||
"brief-1",
|
||||
"finding-0",
|
||||
"not actionable this quarter",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits optional sections when threats/opportunities/positioning are absent", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
import type { QualityReport } from "@/lib/api/sentinel";
|
||||
@@ -18,27 +19,41 @@ function buildReport(overrides: Partial<QualityReport> = {}): QualityReport {
|
||||
{
|
||||
id: "item-0",
|
||||
area: "waivers",
|
||||
observation: "Minor findings in roboco/services/task.py keep getting waived",
|
||||
observation:
|
||||
"Minor findings in roboco/services/task.py keep getting waived",
|
||||
evidence: "5 waived-minor findings this week (prior week: 1)",
|
||||
suggested_action: "Convert to a Pest Control bug task",
|
||||
status: "proposed",
|
||||
},
|
||||
],
|
||||
overall_assessment: "Drift is concentrated in one hotspot file, not systemic yet",
|
||||
overall_assessment:
|
||||
"Drift is concentrated in one hotspot file, not systemic yet",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const { listReports } = vi.hoisted(() => ({
|
||||
const { listReports, approveItem, rejectItem } = vi.hoisted(() => ({
|
||||
listReports: vi.fn(
|
||||
() =>
|
||||
new Promise((r) => {
|
||||
resolveListRef.current = r as (v: unknown) => void;
|
||||
}),
|
||||
),
|
||||
approveItem: vi.fn(async () => ({
|
||||
status: "approved",
|
||||
item_id: "item-0",
|
||||
materialized_task_id: "task-1",
|
||||
detail: "materialized as a Main-PM-owned task",
|
||||
})),
|
||||
rejectItem: vi.fn(async () => ({
|
||||
status: "rejected",
|
||||
item_id: "item-0",
|
||||
detail: "dismissed; feeds the next cycle's prompt",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
sentinelApi: { listReports },
|
||||
sentinelApi: { listReports, approveItem, rejectItem },
|
||||
}));
|
||||
|
||||
import { QualityReportsTab } from "../quality-reports-tab";
|
||||
@@ -100,17 +115,81 @@ describe("QualityReportsTab", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders no approve/reject controls — a report has no queue action", async () => {
|
||||
// Defect 2 fix: a proposed drift item now gets its own approve/dismiss
|
||||
// action even though the report itself stays a read-only report — was
|
||||
// "renders no approve/reject controls".
|
||||
it("renders per-item approve/dismiss controls for a proposed item", async () => {
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /approve/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the actions once an item is approved", async () => {
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([
|
||||
buildReport({
|
||||
items: [
|
||||
{
|
||||
id: "item-0",
|
||||
area: "waivers",
|
||||
observation: "Minor findings keep getting waived",
|
||||
evidence: "5 waived this week",
|
||||
suggested_action: "Convert to a Pest Control bug task",
|
||||
status: "approved",
|
||||
materialized_task_id: "task-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /approve/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /reject/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Approved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("approves an item via the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
await user.click(screen.getByRole("button", { name: /approve/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(approveItem).toHaveBeenCalledWith("report-1", "item-0"),
|
||||
);
|
||||
});
|
||||
|
||||
it("dismisses an item with a reason via the dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(withQueryClient(<QualityReportsTab />));
|
||||
resolveListRef.current?.([buildReport()]);
|
||||
|
||||
await screen.findByText("Waived findings climbed 3x this week");
|
||||
await user.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await user.type(screen.getByLabelText("Reason"), "already tracked");
|
||||
await user.click(
|
||||
within(dialog).getByRole("button", { name: /^dismiss$/i }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(rejectItem).toHaveBeenCalledWith(
|
||||
"report-1",
|
||||
"item-0",
|
||||
"already tracked",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the overall-assessment section when absent", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { coronerApi } from "@/lib/api";
|
||||
import type { Postmortem } from "@/lib/api/coroner";
|
||||
import {
|
||||
@@ -11,10 +12,24 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { CheckCircle2, Stethoscope, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
const INCIDENT_KIND_LABELS: Record<string, string> = {
|
||||
bounced: "bounced 3+ times",
|
||||
@@ -22,7 +37,46 @@ const INCIDENT_KIND_LABELS: Record<string, string> = {
|
||||
budget: "budget-blocked",
|
||||
};
|
||||
|
||||
function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
function processChangeStatusBadge(postmortem: Postmortem) {
|
||||
if (postmortem.process_change_status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (postmortem.process_change_status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (postmortem.process_change_status === "not_applicable") {
|
||||
return (
|
||||
<HelpTip label="Already drafted straight into the playbook review queue — nothing else to decide">
|
||||
<Badge variant="outline">Drafted as playbook</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function PostmortemRow({
|
||||
postmortem,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
postmortem: Postmortem;
|
||||
onApprove: (taskId: string) => void;
|
||||
onReject: (postmortem: Postmortem) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = postmortem.process_change_status === "proposed";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -47,6 +101,7 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
<Badge>{postmortem.process_change_kind}</Badge>
|
||||
</HelpTip>
|
||||
)}
|
||||
{processChangeStatusBadge(postmortem)}
|
||||
</div>
|
||||
{postmortem.incident_summary && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -65,20 +120,58 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
|
||||
{postmortem.process_change_description}
|
||||
</p>
|
||||
)}
|
||||
{postmortem.process_change_status === "rejected" &&
|
||||
postmortem.process_change_reject_reason && (
|
||||
<p className="text-sm text-destructive">
|
||||
Dismissed: {postmortem.process_change_reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{postmortem.completed_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(postmortem.completed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject(postmortem)}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this process change as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(postmortem.task_id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only Postmortems list — a Coroner postmortem completes atomically at
|
||||
// propose_postmortem time (no per-item approve/reject like Pest Control/
|
||||
// Roadmap), so unlike those review queues this never hides on empty; it's a
|
||||
// retrospective report the CEO checks, not an urgent action queue.
|
||||
// Postmortems list — a Coroner postmortem completes atomically at
|
||||
// propose_postmortem time (no per-item approve/reject at the TASK level
|
||||
// like Pest Control/Roadmap), so this never hides on empty; it's a
|
||||
// retrospective report the CEO checks. Its single process change still
|
||||
// carries its own per-item approve/dismiss action, below each row.
|
||||
export function CoronerPostmortemsCard({ className }: { className?: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<Postmortem | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingTaskId, setApprovingTaskId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: postmortems,
|
||||
isLoading,
|
||||
@@ -90,7 +183,64 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["coroner", "postmortems"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => coronerApi.approveProcessChange(taskId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Process change approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingTaskId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
|
||||
coronerApi.rejectProcessChange(taskId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Process change dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string) => {
|
||||
setApprovingTaskId(taskId);
|
||||
approveMutation.mutate(taskId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
@@ -124,11 +274,52 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{postmortems.map((pm) => (
|
||||
<PostmortemRow key={pm.task_id} postmortem={pm} />
|
||||
<PostmortemRow
|
||||
key={pm.task_id}
|
||||
postmortem={pm}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approving={approvingTaskId === pm.task_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss process change</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="coroner-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="coroner-reject-reason"
|
||||
placeholder="e.g. one-off incident; not worth a standing process change..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { periscopeApi } from "@/lib/api";
|
||||
import type { MarketBrief } from "@/lib/api/periscope";
|
||||
import type { MarketBrief, MarketBriefFinding } from "@/lib/api/periscope";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
interface RejectTarget {
|
||||
taskId: string;
|
||||
finding: MarketBriefFinding;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only skeleton placeholder shaped like a brief card
|
||||
@@ -28,12 +49,115 @@ function BriefCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function findingStatusBadge(finding: MarketBriefFinding) {
|
||||
if (finding.status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (finding.status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One brief, read-only — headline/findings/sources/threats/opportunities.
|
||||
// No approve/reject UI: a market brief is a report, not a queue item.
|
||||
// One finding — claim/relevance/source, plus a per-finding approve/dismiss
|
||||
// action (a market signal is worth acting on individually, even though the
|
||||
// brief itself is a report).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
function FindingRow({
|
||||
taskId,
|
||||
finding,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
taskId: string;
|
||||
finding: MarketBriefFinding;
|
||||
onApprove: (taskId: string, findingId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = finding.status === "proposed";
|
||||
|
||||
return (
|
||||
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="whitespace-pre-wrap text-sm">{finding.claim}</p>
|
||||
{findingStatusBadge(finding)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{finding.relevance} —{" "}
|
||||
<a
|
||||
href={finding.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
source
|
||||
</a>
|
||||
</p>
|
||||
{finding.status === "rejected" && finding.reject_reason && (
|
||||
<p className="text-xs text-destructive">
|
||||
Dismissed: {finding.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject({ taskId, finding })}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this finding as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(taskId, finding.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One brief — headline/findings/sources/threats/opportunities, each finding
|
||||
// with its own approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BriefCard({
|
||||
brief,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvingFindingId,
|
||||
}: {
|
||||
brief: MarketBrief;
|
||||
onApprove: (taskId: string, findingId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approvingFindingId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -52,20 +176,14 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{brief.findings.map((f) => (
|
||||
<li key={f.id} className="text-sm">
|
||||
<p className="whitespace-pre-wrap">{f.claim}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{f.relevance} —{" "}
|
||||
<a
|
||||
href={f.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
source
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
taskId={brief.task_id}
|
||||
finding={f}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
approving={approvingFindingId === f.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -125,6 +243,13 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MarketBriefsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingFindingId, setApprovingFindingId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const {
|
||||
data: briefs = [],
|
||||
isLoading,
|
||||
@@ -136,7 +261,81 @@ export function MarketBriefsTab() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["periscope", "briefs"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
findingId,
|
||||
}: {
|
||||
taskId: string;
|
||||
findingId: string;
|
||||
}) => periscopeApi.approveFinding(taskId, findingId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Finding approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingFindingId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
findingId,
|
||||
reason,
|
||||
}: {
|
||||
taskId: string;
|
||||
findingId: string;
|
||||
reason: string;
|
||||
}) => periscopeApi.rejectFinding(taskId, findingId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Finding dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({
|
||||
taskId: rejecting.taskId,
|
||||
findingId: rejecting.finding.id,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string, findingId: string) => {
|
||||
setApprovingFindingId(findingId);
|
||||
approveMutation.mutate({ taskId, findingId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Market Briefs</CardTitle>
|
||||
@@ -156,17 +355,58 @@ export function MarketBriefsTab() {
|
||||
) : briefs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No market briefs filed yet. The Head of Marketing files one
|
||||
weekly, once Periscope is enabled — they appear here as reports,
|
||||
not a queue to act on.
|
||||
weekly, once Periscope is enabled — each cited finding can be
|
||||
approved or dismissed individually once one lands.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{briefs.map((b) => (
|
||||
<BriefCard key={b.task_id} brief={b} />
|
||||
<BriefCard
|
||||
key={b.task_id}
|
||||
brief={b}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approvingFindingId={approvingFindingId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss market-brief finding</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="periscope-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="periscope-reject-reason"
|
||||
placeholder="e.g. not actionable right now; already covered by an open task..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { sentinelApi } from "@/lib/api";
|
||||
import type { QualityReport } from "@/lib/api/sentinel";
|
||||
import type { QualityReport, QualityReportItem } from "@/lib/api/sentinel";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const _MIN_REASON_CHARS = 4;
|
||||
|
||||
interface RejectTarget {
|
||||
taskId: string;
|
||||
item: QualityReportItem;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only skeleton placeholder shaped like a report card
|
||||
@@ -27,12 +49,112 @@ function ReportCardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function itemStatusBadge(item: QualityReportItem) {
|
||||
if (item.status === "approved") {
|
||||
return (
|
||||
<HelpTip label="Materialized as a Main-PM-owned task">
|
||||
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
|
||||
Approved
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
if (item.status === "rejected") {
|
||||
return (
|
||||
<HelpTip label="Dismissed — not added to the backlog">
|
||||
<Badge variant="outline">Dismissed</Badge>
|
||||
</HelpTip>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One report, read-only — headline/items/overall assessment. No approve/
|
||||
// reject UI: a quality report is a report, not a queue item.
|
||||
// One drift item — area/observation/evidence/suggested action, plus a
|
||||
// per-item approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReportCard({ report }: { report: QualityReport }) {
|
||||
function DriftItemRow({
|
||||
taskId,
|
||||
item,
|
||||
onApprove,
|
||||
onReject,
|
||||
approving,
|
||||
}: {
|
||||
taskId: string;
|
||||
item: QualityReportItem;
|
||||
onApprove: (taskId: string, itemId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const isProposed = item.status === "proposed";
|
||||
|
||||
return (
|
||||
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="uppercase text-xs">
|
||||
{item.area}
|
||||
</Badge>
|
||||
{itemStatusBadge(item)}
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{item.observation}</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Evidence: {item.evidence}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Suggested action: {item.suggested_action}
|
||||
</p>
|
||||
{item.status === "rejected" && item.reject_reason && (
|
||||
<p className="text-xs text-destructive">
|
||||
Dismissed: {item.reject_reason}
|
||||
</p>
|
||||
)}
|
||||
{isProposed && (
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<HelpTip label="Records your reason — not added to the backlog">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => onReject({ taskId, item })}
|
||||
>
|
||||
<XCircle className="mr-1 h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</HelpTip>
|
||||
<HelpTip label="Materializes this item as a Main-PM-owned task — needs normal PM activation to start">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
disabled={approving}
|
||||
onClick={() => onApprove(taskId, item.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</HelpTip>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One report — headline/items/overall assessment, each item with its own
|
||||
// approve/dismiss action.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReportCard({
|
||||
report,
|
||||
onApprove,
|
||||
onReject,
|
||||
approvingItemId,
|
||||
}: {
|
||||
report: QualityReport;
|
||||
onApprove: (taskId: string, itemId: string) => void;
|
||||
onReject: (target: RejectTarget) => void;
|
||||
approvingItemId: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -51,18 +173,14 @@ function ReportCard({ report }: { report: QualityReport }) {
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{report.items.map((item) => (
|
||||
<li key={item.id} className="text-sm space-y-1">
|
||||
<Badge variant="secondary" className="uppercase text-xs">
|
||||
{item.area}
|
||||
</Badge>
|
||||
<p className="whitespace-pre-wrap">{item.observation}</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Evidence: {item.evidence}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
Suggested action: {item.suggested_action}
|
||||
</p>
|
||||
</li>
|
||||
<DriftItemRow
|
||||
key={item.id}
|
||||
taskId={report.task_id}
|
||||
item={item}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
approving={approvingItemId === item.id}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -87,6 +205,11 @@ function ReportCard({ report }: { report: QualityReport }) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function QualityReportsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: reports = [],
|
||||
isLoading,
|
||||
@@ -98,7 +221,76 @@ export function QualityReportsTab() {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["sentinel", "reports"] });
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
|
||||
sentinelApi.approveItem(taskId, itemId),
|
||||
onSuccess: (result) => {
|
||||
invalidate();
|
||||
if (
|
||||
result.status === "approved" ||
|
||||
result.status === "already_approved"
|
||||
) {
|
||||
toast.success("Item approved — materialized as a task");
|
||||
} else {
|
||||
toast.warning(result.detail);
|
||||
}
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
onSettled: () => setApprovingItemId(null),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
itemId,
|
||||
reason,
|
||||
}: {
|
||||
taskId: string;
|
||||
itemId: string;
|
||||
reason: string;
|
||||
}) => sentinelApi.rejectItem(taskId, itemId, reason),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
toast.success("Item dismissed");
|
||||
closeReject();
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
|
||||
),
|
||||
});
|
||||
|
||||
const closeReject = () => {
|
||||
setRejecting(null);
|
||||
setReason("");
|
||||
};
|
||||
|
||||
const confirmReject = () => {
|
||||
if (!rejecting) return;
|
||||
if (reason.trim().length < _MIN_REASON_CHARS) {
|
||||
toast.error("Give a brief reason for dismissing");
|
||||
return;
|
||||
}
|
||||
rejectMutation.mutate({
|
||||
taskId: rejecting.taskId,
|
||||
itemId: rejecting.item.id,
|
||||
reason: reason.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApprove = (taskId: string, itemId: string) => {
|
||||
setApprovingItemId(itemId);
|
||||
approveMutation.mutate({ taskId, itemId });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quality Reports</CardTitle>
|
||||
@@ -118,17 +310,58 @@ export function QualityReportsTab() {
|
||||
) : reports.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No quality reports filed yet. The Auditor files one weekly, once
|
||||
Sentinel is enabled — they appear here as reports, not a queue to
|
||||
act on.
|
||||
Sentinel is enabled — each drift item can be approved or dismissed
|
||||
individually once one lands.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reports.map((r) => (
|
||||
<ReportCard key={r.task_id} report={r} />
|
||||
<ReportCard
|
||||
key={r.task_id}
|
||||
report={r}
|
||||
onApprove={handleApprove}
|
||||
onReject={setRejecting}
|
||||
approvingItemId={approvingItemId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Dismiss quality-report item</DialogTitle>
|
||||
<DialogDescription>
|
||||
This records your reason and feeds the next cycle's prompt —
|
||||
it is not added to the backlog.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sentinel-reject-reason">Reason</Label>
|
||||
<Textarea
|
||||
id="sentinel-reject-reason"
|
||||
placeholder="e.g. already tracked elsewhere; not worth a task this cycle..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmReject}
|
||||
disabled={rejectMutation.isPending}
|
||||
>
|
||||
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import api from "./client";
|
||||
// Coroner (Board Program) engine — the Auditor's event-triggered postmortem:
|
||||
// a task bounced >=3x, was cancelled after work started, or was budget-
|
||||
// blocked. One propose_postmortem call completes the autopsy atomically —
|
||||
// there is no per-item approve/reject like Pest Control/Roadmap, so this is
|
||||
// a plain read-only list. Mirrors lib/api/pest-control.ts.
|
||||
// the EXPLORATION TASK has no per-item decision to wait on — but the
|
||||
// postmortem's single process change still carries its own proposed/
|
||||
// approved/rejected status the CEO decides on afterward (unless it already
|
||||
// drafted a playbook: process_change_status "not_applicable", nothing left
|
||||
// to decide). Unlike Periscope/Sentinel there is no item id — a postmortem
|
||||
// is one process change, not a list — so the action routes key on the task
|
||||
// id alone.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Postmortem {
|
||||
@@ -21,6 +26,16 @@ export interface Postmortem {
|
||||
process_change_kind: string | null;
|
||||
process_change_description: string | null;
|
||||
playbook_id: string | null;
|
||||
process_change_status:
|
||||
"proposed" | "approved" | "rejected" | "not_applicable";
|
||||
process_change_reject_reason: string | null;
|
||||
process_change_materialized_task_id: string | null;
|
||||
}
|
||||
|
||||
export interface ProcessChangeActionResult {
|
||||
status: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const coronerApi = {
|
||||
@@ -28,4 +43,23 @@ export const coronerApi = {
|
||||
const { data } = await api.get<Postmortem[]>("/coroner/postmortems");
|
||||
return data;
|
||||
},
|
||||
approveProcessChange: async (
|
||||
taskId: string,
|
||||
): Promise<ProcessChangeActionResult> => {
|
||||
const { data } = await api.post<ProcessChangeActionResult>(
|
||||
`/coroner/postmortems/${taskId}/process-change/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectProcessChange: async (
|
||||
taskId: string,
|
||||
reason: string,
|
||||
): Promise<ProcessChangeActionResult> => {
|
||||
const { data } = await api.post<ProcessChangeActionResult>(
|
||||
`/coroner/postmortems/${taskId}/process-change/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ import api from "./client";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Periscope (Board Program) engine — the Head of Marketing files a weekly
|
||||
// market-research brief (competitors, adjacent-tool releases, positioning
|
||||
// shifts). Unlike Roadmap/Pest Control this is a REPORT, not a queue item:
|
||||
// read-only here, no approve/reject. Mirrors lib/api/pest-control.ts's shape
|
||||
// minus the mutating routes.
|
||||
// shifts). The brief itself is read-only — a report, not a queue item, the
|
||||
// exploration task completes atomically at propose time — but each cited
|
||||
// finding still carries its own proposed/approved/rejected status the CEO
|
||||
// decides on afterward. Mirrors lib/api/roadmap.ts's per-item approve/reject
|
||||
// shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MarketBriefFinding {
|
||||
@@ -13,6 +15,9 @@ export interface MarketBriefFinding {
|
||||
claim: string;
|
||||
source_url: string;
|
||||
relevance: string;
|
||||
status: "proposed" | "approved" | "rejected";
|
||||
reject_reason?: string | null;
|
||||
materialized_task_id?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketBrief {
|
||||
@@ -26,9 +31,37 @@ export interface MarketBrief {
|
||||
positioning_note: string;
|
||||
}
|
||||
|
||||
export interface MarketBriefFindingActionResult {
|
||||
status: string;
|
||||
finding_id: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const periscopeApi = {
|
||||
listBriefs: async (): Promise<MarketBrief[]> => {
|
||||
const { data } = await api.get<MarketBrief[]>("/periscope/briefs");
|
||||
return data;
|
||||
},
|
||||
approveFinding: async (
|
||||
taskId: string,
|
||||
findingId: string,
|
||||
): Promise<MarketBriefFindingActionResult> => {
|
||||
const { data } = await api.post<MarketBriefFindingActionResult>(
|
||||
`/periscope/briefs/${taskId}/findings/${findingId}/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectFinding: async (
|
||||
taskId: string,
|
||||
findingId: string,
|
||||
reason: string,
|
||||
): Promise<MarketBriefFindingActionResult> => {
|
||||
const { data } = await api.post<MarketBriefFindingActionResult>(
|
||||
`/periscope/briefs/${taskId}/findings/${findingId}/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,9 +3,11 @@ import api from "./client";
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sentinel (Board Program) engine — the Auditor files a weekly "state of
|
||||
// quality" report (waiver-accumulation trends, conventions-violation
|
||||
// hotspots, budget anomalies). Like Periscope this is a REPORT, not a queue
|
||||
// item: read-only here, no approve/reject. Mirrors lib/api/periscope.ts's
|
||||
// shape exactly.
|
||||
// hotspots, budget anomalies). The report itself is read-only — a report,
|
||||
// not a queue item, the exploration task completes atomically at propose
|
||||
// time — but each drift item still carries its own proposed/approved/
|
||||
// rejected status the CEO decides on afterward. Mirrors lib/api/
|
||||
// periscope.ts's per-item approve/reject shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface QualityReportItem {
|
||||
@@ -14,6 +16,9 @@ export interface QualityReportItem {
|
||||
observation: string;
|
||||
evidence: string;
|
||||
suggested_action: string;
|
||||
status: "proposed" | "approved" | "rejected";
|
||||
reject_reason?: string | null;
|
||||
materialized_task_id?: string | null;
|
||||
}
|
||||
|
||||
export interface QualityReport {
|
||||
@@ -25,9 +30,37 @@ export interface QualityReport {
|
||||
overall_assessment: string;
|
||||
}
|
||||
|
||||
export interface QualityReportItemActionResult {
|
||||
status: string;
|
||||
item_id: string;
|
||||
materialized_task_id?: string | null;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export const sentinelApi = {
|
||||
listReports: async (): Promise<QualityReport[]> => {
|
||||
const { data } = await api.get<QualityReport[]>("/sentinel/reports");
|
||||
return data;
|
||||
},
|
||||
approveItem: async (
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
): Promise<QualityReportItemActionResult> => {
|
||||
const { data } = await api.post<QualityReportItemActionResult>(
|
||||
`/sentinel/reports/${taskId}/items/${itemId}/approve`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
},
|
||||
rejectItem: async (
|
||||
taskId: string,
|
||||
itemId: string,
|
||||
reason: string,
|
||||
): Promise<QualityReportItemActionResult> => {
|
||||
const { data } = await api.post<QualityReportItemActionResult>(
|
||||
`/sentinel/reports/${taskId}/items/${itemId}/reject`,
|
||||
{ reason },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""Coroner (Board Program) engine API — read-only Postmortems list.
|
||||
"""Coroner (Board Program) engine API — the CEO reads filed postmortems and
|
||||
approves/dismisses each one's process change.
|
||||
|
||||
Unlike Pest Control/Roadmap there is nothing here for the CEO to approve or
|
||||
reject: a postmortem completes atomically the moment the Auditor calls
|
||||
``propose_postmortem`` (spec §4). This route just lists what Coroner has
|
||||
already found. CEO-only, mirroring every other Board Program surface.
|
||||
A postmortem completes atomically at ``propose_postmortem`` time — the
|
||||
EXPLORATION TASK has no per-item decision to wait on — but its single
|
||||
process change still carries its own proposed/approved/rejected status the
|
||||
CEO decides on afterward (unless kind="playbook", already routed into the
|
||||
playbook queue). Unlike Periscope/Sentinel there is no item id: a postmortem
|
||||
is one process change, not a list, so the action routes key on the task id
|
||||
alone. CEO-only, mirroring every other Board Program surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.coroner import PostmortemResponse
|
||||
from roboco.api.schemas.coroner import (
|
||||
PostmortemResponse,
|
||||
ProcessChangeActionResponse,
|
||||
ProcessChangeRejectRequest,
|
||||
)
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.coroner_service import get_coroner_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -24,7 +35,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="view the Coroner postmortems list")
|
||||
require_ceo_role(agent.role, action="view or act on the Coroner postmortems list")
|
||||
|
||||
|
||||
def _to_response(task: TaskTable) -> PostmortemResponse:
|
||||
@@ -44,6 +55,9 @@ def _to_response(task: TaskTable) -> PostmortemResponse:
|
||||
process_change_kind=process_change.get("kind"),
|
||||
process_change_description=process_change.get("description"),
|
||||
playbook_id=postmortem.get("playbook_id"),
|
||||
process_change_status=process_change.get("status", "proposed"),
|
||||
process_change_reject_reason=process_change.get("reject_reason"),
|
||||
process_change_materialized_task_id=process_change.get("materialized_task_id"),
|
||||
)
|
||||
|
||||
|
||||
@@ -55,3 +69,63 @@ async def list_postmortems(
|
||||
_require_ceo(agent)
|
||||
tasks = await get_task_service(db).list_completed_coroner_postmortems()
|
||||
return [_to_response(t) for t in tasks]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postmortems/{task_id}/process-change/approve",
|
||||
response_model=ProcessChangeActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
async def approve_process_change(
|
||||
task_id: UUID,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> ProcessChangeActionResponse:
|
||||
"""Materialize the postmortem's process change as a Main-PM-owned root
|
||||
task (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_coroner_service(db).approve_process_change(
|
||||
task_id, created_by=agent.agent_id
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such Coroner postmortem",
|
||||
)
|
||||
await db.commit()
|
||||
return ProcessChangeActionResponse(
|
||||
status=result.status,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/postmortems/{task_id}/process-change/reject",
|
||||
response_model=ProcessChangeActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
async def reject_process_change(
|
||||
task_id: UUID,
|
||||
data: ProcessChangeRejectRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> ProcessChangeActionResponse:
|
||||
"""Dismiss the postmortem's process change with a reason (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_coroner_service(db).reject_process_change(task_id, data.reason)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such Coroner postmortem",
|
||||
)
|
||||
await db.commit()
|
||||
return ProcessChangeActionResponse(
|
||||
status=result.status,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
"""Periscope (Board Program) engine API — the CEO reads filed market briefs.
|
||||
CEO-only throughout. Read-only: a brief is a report, not a queue item — there
|
||||
is no approve/reject route here, unlike roadmap/pest_control. Mirrors
|
||||
``roboco.api.routes.pest_control``'s CEO-gating shape.
|
||||
"""Periscope (Board Program) engine API — the CEO reads filed market briefs
|
||||
and approves/dismisses individual findings. CEO-only throughout. The brief
|
||||
itself is a report (read-only — the exploration task completes atomically at
|
||||
propose time), but each finding carries its own per-item approve/reject,
|
||||
mirroring ``roboco.api.routes.roadmap``'s shape.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.periscope import MarketBriefFindingResponse, MarketBriefResponse
|
||||
from roboco.api.schemas.periscope import (
|
||||
MarketBriefFindingActionResponse,
|
||||
MarketBriefFindingRejectRequest,
|
||||
MarketBriefFindingResponse,
|
||||
MarketBriefResponse,
|
||||
)
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.periscope_service import get_periscope_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -20,7 +29,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="view the Periscope market-briefs list")
|
||||
require_ceo_role(
|
||||
agent.role, action="view or act on the Periscope market-briefs list"
|
||||
)
|
||||
|
||||
|
||||
def _to_response(task: "TaskTable") -> MarketBriefResponse | None:
|
||||
@@ -50,3 +61,68 @@ async def list_market_briefs(
|
||||
_require_ceo(agent)
|
||||
tasks = await get_task_service(db).list_periscope_briefs()
|
||||
return [r for t in tasks if (r := _to_response(t)) is not None]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/briefs/{task_id}/findings/{finding_id}/approve",
|
||||
response_model=MarketBriefFindingActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
async def approve_market_brief_finding(
|
||||
task_id: UUID,
|
||||
finding_id: str,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> MarketBriefFindingActionResponse:
|
||||
"""Materialize one finding as a Main-PM-owned root task (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_periscope_service(db).approve_finding(
|
||||
task_id, finding_id, created_by=agent.agent_id
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such open Periscope finding",
|
||||
)
|
||||
await db.commit()
|
||||
return MarketBriefFindingActionResponse(
|
||||
status=result.status,
|
||||
finding_id=result.finding_id,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/briefs/{task_id}/findings/{finding_id}/reject",
|
||||
response_model=MarketBriefFindingActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
async def reject_market_brief_finding(
|
||||
task_id: UUID,
|
||||
finding_id: str,
|
||||
data: MarketBriefFindingRejectRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> MarketBriefFindingActionResponse:
|
||||
"""Dismiss one finding with a reason (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_periscope_service(db).reject_finding(
|
||||
task_id, finding_id, data.reason
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such open Periscope finding",
|
||||
)
|
||||
await db.commit()
|
||||
return MarketBriefFindingActionResponse(
|
||||
status=result.status,
|
||||
finding_id=result.finding_id,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
"""Sentinel (Board Program) engine API — the CEO reads filed quality reports.
|
||||
CEO-only throughout. Read-only: a report is a report, not a queue item — there
|
||||
is no approve/reject route here, unlike roadmap/pest_control. Mirrors
|
||||
``roboco.api.routes.periscope``'s CEO-gating shape.
|
||||
"""Sentinel (Board Program) engine API — the CEO reads filed quality reports
|
||||
and approves/dismisses individual drift items. CEO-only throughout. The
|
||||
report itself is read-only (the exploration task completes atomically at
|
||||
propose time), but each item carries its own per-item approve/reject,
|
||||
mirroring ``roboco.api.routes.periscope``'s shape.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.sentinel import QualityReportItemResponse, QualityReportResponse
|
||||
from roboco.api.schemas.sentinel import (
|
||||
QualityReportItemActionResponse,
|
||||
QualityReportItemRejectRequest,
|
||||
QualityReportItemResponse,
|
||||
QualityReportResponse,
|
||||
)
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.sentinel_service import get_sentinel_service
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -20,7 +29,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="view the Sentinel quality-reports list")
|
||||
require_ceo_role(
|
||||
agent.role, action="view or act on the Sentinel quality-reports list"
|
||||
)
|
||||
|
||||
|
||||
def _to_response(task: "TaskTable") -> QualityReportResponse | None:
|
||||
@@ -48,3 +59,66 @@ async def list_quality_reports(
|
||||
_require_ceo(agent)
|
||||
tasks = await get_task_service(db).list_sentinel_reports()
|
||||
return [r for t in tasks if (r := _to_response(t)) is not None]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reports/{task_id}/items/{item_id}/approve",
|
||||
response_model=QualityReportItemActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
async def approve_quality_report_item(
|
||||
task_id: UUID,
|
||||
item_id: str,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> QualityReportItemActionResponse:
|
||||
"""Materialize one drift item as a Main-PM-owned root task (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_sentinel_service(db).approve_item(
|
||||
task_id, item_id, created_by=agent.agent_id
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such open Sentinel item",
|
||||
)
|
||||
await db.commit()
|
||||
return QualityReportItemActionResponse(
|
||||
status=result.status,
|
||||
item_id=result.item_id,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reports/{task_id}/items/{item_id}/reject",
|
||||
response_model=QualityReportItemActionResponse,
|
||||
)
|
||||
@guard_deco.rate_limit(requests=30, window=60)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
async def reject_quality_report_item(
|
||||
task_id: UUID,
|
||||
item_id: str,
|
||||
data: QualityReportItemRejectRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext,
|
||||
) -> QualityReportItemActionResponse:
|
||||
"""Dismiss one drift item with a reason (idempotent)."""
|
||||
_require_ceo(agent)
|
||||
result = await get_sentinel_service(db).reject_item(task_id, item_id, data.reason)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No such open Sentinel item",
|
||||
)
|
||||
await db.commit()
|
||||
return QualityReportItemActionResponse(
|
||||
status=result.status,
|
||||
item_id=result.item_id,
|
||||
materialized_task_id=result.materialized_task_id,
|
||||
detail=result.detail,
|
||||
)
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"""Schemas for the Coroner (Board Program) engine's read-only CEO surface.
|
||||
"""Schemas for the Coroner (Board Program) engine's CEO surface.
|
||||
|
||||
Unlike Pest Control/Roadmap there is no approve/reject action here — a
|
||||
postmortem completes atomically at ``propose_postmortem`` time (spec §4:
|
||||
"report asymmetry — no per-item CEO decision"), so this is a plain list.
|
||||
A postmortem completes atomically at ``propose_postmortem`` time — the
|
||||
EXPLORATION TASK has no per-item CEO decision to wait on — but its single
|
||||
``process_change`` still carries its own proposed/approved/rejected status
|
||||
for the CEO to decide on afterward (unless its kind is "playbook", already
|
||||
routed into the playbook curation queue: status "not_applicable"). Unlike
|
||||
Periscope/Sentinel there is no item id — a postmortem is one process change,
|
||||
not a list (``roboco.services.coroner_engine``'s own docstring), so the
|
||||
action routes key on the task id alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PostmortemResponse(BaseModel):
|
||||
@@ -26,3 +31,23 @@ class PostmortemResponse(BaseModel):
|
||||
process_change_kind: str | None
|
||||
process_change_description: str | None
|
||||
playbook_id: str | None
|
||||
# Defaults cover a postmortem authored before this feature shipped,
|
||||
# whose stored process_change carries none of these three keys.
|
||||
process_change_status: str = "proposed"
|
||||
process_change_reject_reason: str | None = None
|
||||
process_change_materialized_task_id: str | None = None
|
||||
|
||||
|
||||
class ProcessChangeRejectRequest(BaseModel):
|
||||
"""The CEO's reason for dismissing a postmortem's process change."""
|
||||
|
||||
reason: str = Field(..., min_length=4)
|
||||
|
||||
|
||||
class ProcessChangeActionResponse(BaseModel):
|
||||
"""The outcome of an approve/reject call on a postmortem's process
|
||||
change."""
|
||||
|
||||
status: str
|
||||
materialized_task_id: str | None = None
|
||||
detail: str
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Schemas for the Periscope (Board Program) engine's CEO-facing read surface.
|
||||
Mirrors ``roboco.api.schemas.pest_control`` — a report has no per-item
|
||||
approve/reject, so this is list-only."""
|
||||
"""Schemas for the Periscope (Board Program) engine's CEO-facing surface.
|
||||
|
||||
The report (headline/findings/threats/opportunities/positioning_note) is
|
||||
read-only — a brief has no per-item approve/reject at the TASK level (the
|
||||
exploration task completes atomically at propose time). Each FINDING still
|
||||
carries its own proposed/approved/rejected status the CEO decides on
|
||||
afterward — ``MarketBriefFindingResponse`` and the action schemas below back
|
||||
that per-finding queue, mirrored on ``roboco.api.schemas.roadmap``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MarketBriefFindingResponse(BaseModel):
|
||||
@@ -14,6 +19,11 @@ class MarketBriefFindingResponse(BaseModel):
|
||||
claim: str
|
||||
source_url: str
|
||||
relevance: str
|
||||
# Defaults cover a finding authored before this feature shipped, whose
|
||||
# stored marker carries none of these three keys.
|
||||
status: str = "proposed"
|
||||
reject_reason: str | None = None
|
||||
materialized_task_id: str | None = None
|
||||
|
||||
|
||||
class MarketBriefResponse(BaseModel):
|
||||
@@ -27,3 +37,18 @@ class MarketBriefResponse(BaseModel):
|
||||
threats: list[str]
|
||||
opportunities: list[str]
|
||||
positioning_note: str
|
||||
|
||||
|
||||
class MarketBriefFindingRejectRequest(BaseModel):
|
||||
"""The CEO's reason for dismissing one market-brief finding."""
|
||||
|
||||
reason: str = Field(..., min_length=4)
|
||||
|
||||
|
||||
class MarketBriefFindingActionResponse(BaseModel):
|
||||
"""The outcome of an approve/reject call on one market-brief finding."""
|
||||
|
||||
status: str
|
||||
finding_id: str
|
||||
materialized_task_id: str | None = None
|
||||
detail: str
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""Schemas for the Sentinel (Board Program) engine's CEO-facing read surface.
|
||||
Mirrors ``roboco.api.schemas.periscope`` — a report has no per-item
|
||||
approve/reject, so this is list-only."""
|
||||
"""Schemas for the Sentinel (Board Program) engine's CEO-facing surface.
|
||||
|
||||
Mirrors ``roboco.api.schemas.periscope`` exactly: the report itself is
|
||||
read-only (the exploration task completes atomically at propose time), but
|
||||
each drift ITEM still carries its own proposed/approved/rejected status the
|
||||
CEO decides on afterward."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QualityReportItemResponse(BaseModel):
|
||||
@@ -15,6 +18,11 @@ class QualityReportItemResponse(BaseModel):
|
||||
observation: str
|
||||
evidence: str
|
||||
suggested_action: str
|
||||
# Defaults cover an item authored before this feature shipped, whose
|
||||
# stored marker carries none of these three keys.
|
||||
status: str = "proposed"
|
||||
reject_reason: str | None = None
|
||||
materialized_task_id: str | None = None
|
||||
|
||||
|
||||
class QualityReportResponse(BaseModel):
|
||||
@@ -26,3 +34,18 @@ class QualityReportResponse(BaseModel):
|
||||
headline: str
|
||||
items: list[QualityReportItemResponse]
|
||||
overall_assessment: str
|
||||
|
||||
|
||||
class QualityReportItemRejectRequest(BaseModel):
|
||||
"""The CEO's reason for dismissing one quality-report item."""
|
||||
|
||||
reason: str = Field(..., min_length=4)
|
||||
|
||||
|
||||
class QualityReportItemActionResponse(BaseModel):
|
||||
"""The outcome of an approve/reject call on one quality-report item."""
|
||||
|
||||
status: str
|
||||
item_id: str
|
||||
materialized_task_id: str | None = None
|
||||
detail: str
|
||||
|
||||
@@ -355,11 +355,15 @@ def set_messaging_fixes(task: HasMarkers, payload: dict[str, Any]) -> None:
|
||||
# --- Periscope market brief --------------------------------------------------
|
||||
# The Head of Marketing's weekly market-research report, authored via
|
||||
# ``propose_market_brief`` onto the exploration task the Periscope engine
|
||||
# opened: {headline, findings (list of {id, claim, source_url, relevance}),
|
||||
# threats, opportunities, positioning_note, injection_hits}. Unlike
|
||||
# ROADMAP_CYCLE/PEST_HUNT there is no per-item CEO decision — the verb
|
||||
# completes the exploration task in the same call (mirrors X_FEATURE_REF's
|
||||
# complete-at-propose asymmetry), so this marker is set exactly once.
|
||||
# opened: {headline, findings (list of {id, claim, source_url, relevance,
|
||||
# status, reject_reason, materialized_task_id}), threats, opportunities,
|
||||
# positioning_note, injection_hits}. Unlike ROADMAP_CYCLE/PEST_HUNT the
|
||||
# EXPLORATION TASK itself has no per-item decision to wait on — the verb
|
||||
# completes it in the same call (mirrors X_FEATURE_REF's complete-at-propose
|
||||
# asymmetry), so this marker is set exactly once. Each FINDING still carries
|
||||
# its own proposed/approved/rejected status the CEO decides afterward
|
||||
# (PeriscopeService.approve_finding/reject_finding), independent of the
|
||||
# task's own terminal status.
|
||||
|
||||
|
||||
def get_market_brief(task: HasMarkers) -> dict[str, Any] | None:
|
||||
@@ -375,9 +379,15 @@ def set_market_brief(task: HasMarkers, payload: dict[str, Any]) -> None:
|
||||
# The incident ref the CoronerEngine stamps on the postmortem-exploration task
|
||||
# it opens ({incident_task_id, kind, revision_count, title}), and the
|
||||
# Auditor-authored postmortem ({incident_summary, root_cause, failed_stage,
|
||||
# process_change, playbook_id?}) it writes via ``propose_postmortem``. Unlike
|
||||
# ROADMAP_CYCLE/PEST_HUNT there is no per-item status — a single call
|
||||
# completes the task, so this is set-once, read-only after that.
|
||||
# process_change: {kind, description, status, reject_reason,
|
||||
# materialized_task_id}, playbook_id?}) it writes via ``propose_postmortem``.
|
||||
# A single call completes the EXPLORATION TASK — unlike ROADMAP_CYCLE/
|
||||
# PEST_HUNT there is no multi-item queue to keep it open for — but the one
|
||||
# ``process_change`` still carries its own proposed/approved/rejected status
|
||||
# for the CEO's after-the-fact decision (CoronerService.
|
||||
# approve_process_change/reject_process_change), except when its kind is
|
||||
# "playbook" (status "not_applicable" — already routed straight into the
|
||||
# playbook curation queue, nothing left to decide here).
|
||||
|
||||
|
||||
def get_coroner_incident(task: HasMarkers) -> dict[str, Any] | None:
|
||||
@@ -402,9 +412,11 @@ def set_coroner_postmortem(task: HasMarkers, payload: dict[str, Any]) -> None:
|
||||
# The Auditor's weekly org-wide drift report, authored via
|
||||
# ``propose_quality_report`` onto the exploration task the sentinel engine
|
||||
# opened: {headline, items (list of {id, area, observation, evidence,
|
||||
# suggested_action}), overall_assessment}. Mirrors MARKET_BRIEF exactly — no
|
||||
# per-item CEO decision, so this marker is set exactly once (complete-at-
|
||||
# propose).
|
||||
# suggested_action, status, reject_reason, materialized_task_id}),
|
||||
# overall_assessment}. Mirrors MARKET_BRIEF exactly — the exploration task
|
||||
# is set exactly once (complete-at-propose), but each ITEM still carries its
|
||||
# own proposed/approved/rejected status the CEO decides afterward
|
||||
# (SentinelService.approve_item/reject_item).
|
||||
|
||||
|
||||
def get_quality_report(task: HasMarkers) -> dict[str, Any] | None:
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""CoronerService — the CEO's approve/dismiss glue over a completed
|
||||
Coroner postmortem's ONE process change.
|
||||
|
||||
The Coroner engine opens a HELD, EVENT-triggered postmortem-exploration task
|
||||
(``board_coroner`` source) when an incident task bounces 3+ times, is
|
||||
cancelled after work started, or is budget-blocked. The Auditor autopsies it
|
||||
and files the postmortem via ``propose_postmortem`` (persisted as a marker
|
||||
payload — see
|
||||
``roboco.foundation.policy.content.markers.get_coroner_postmortem``), which
|
||||
completes the exploration task in that same call — a report, not a per-item
|
||||
queue.
|
||||
|
||||
Unlike Periscope/Sentinel, a postmortem is ONE process change, not a list of
|
||||
items (``roboco.services.coroner_engine``'s own docstring: "a postmortem is
|
||||
one process change, not a list of items") — so there is no item id to key
|
||||
on, just the task id. ``approve_process_change`` materializes it as a
|
||||
PENDING, Main-PM-owned root task (``source=coroner``, ``assigned_to=
|
||||
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
|
||||
parentless BACKLOG task); ``reject_process_change`` records the reason
|
||||
("dismiss" — no task). Both are idempotent. A ``kind="playbook"`` change
|
||||
already routed straight into the playbook curation queue at propose time
|
||||
(``ContentActions._draft_coroner_playbook``) — it carries marker status
|
||||
``not_applicable`` and both actions here refuse it outright (result status
|
||||
``invalid_state``): nothing is left to decide.
|
||||
|
||||
A process change carries no ``project_slug`` the way a roadmap item does.
|
||||
The target project resolves to the INCIDENT task's own project (re-fetched
|
||||
via the ``coroner_incident`` marker's ``incident_task_id`` — the same
|
||||
resolution ``CoronerEngine._originate`` already uses to anchor the
|
||||
postmortem-exploration task itself: "Project is the incident's own — an
|
||||
autopsy is about that incident, wherever it lived"), falling back to
|
||||
RoboCo's own project (``settings.self_heal_project_slug``) when the incident
|
||||
is gone or carries no project — mirroring ``CoronerEngine._originate``'s own
|
||||
``incident.project_id or await self._roboco_project_id()`` fallback exactly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import CORONER_ITEM_SOURCE, CORONER_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
|
||||
_PLAYBOOK_KIND = "playbook"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessChangeResult:
|
||||
"""Outcome of an approve/reject call on a postmortem's process change.
|
||||
|
||||
`status` is one of: approved, already_approved, rejected,
|
||||
already_rejected, invalid_state.
|
||||
"""
|
||||
|
||||
status: str
|
||||
materialized_task_id: str | None
|
||||
detail: str
|
||||
|
||||
|
||||
class CoronerService(BaseService):
|
||||
"""Approve / reject the process change on a completed Coroner postmortem."""
|
||||
|
||||
service_name = "coroner_service"
|
||||
|
||||
async def approve_process_change(
|
||||
self, task_id: UUID, *, created_by: UUID
|
||||
) -> ProcessChangeResult | None:
|
||||
"""Materialize the postmortem's process change as a Main-PM-owned
|
||||
root task.
|
||||
|
||||
Returns None when ``task_id`` carries no Coroner postmortem.
|
||||
Idempotent: an already-approved change returns its stored
|
||||
materialized task id without creating a duplicate. An
|
||||
already-rejected change cannot be approved. A ``kind="playbook"``
|
||||
change refuses outright — it already drafted into the playbook
|
||||
queue at propose time.
|
||||
"""
|
||||
task, payload, process_change = await self._find(task_id)
|
||||
if task is None or payload is None or process_change is None:
|
||||
return None
|
||||
if process_change["kind"] == _PLAYBOOK_KIND:
|
||||
return self._playbook_result()
|
||||
if process_change["status"] == "approved":
|
||||
return ProcessChangeResult(
|
||||
status="already_approved",
|
||||
materialized_task_id=process_change.get("materialized_task_id"),
|
||||
detail="this process change was already approved",
|
||||
)
|
||||
if process_change["status"] != "proposed":
|
||||
return ProcessChangeResult(
|
||||
status="invalid_state",
|
||||
materialized_task_id=None,
|
||||
detail=(
|
||||
f"process change is {process_change['status']!r}, not "
|
||||
"proposed — cannot approve"
|
||||
),
|
||||
)
|
||||
try:
|
||||
new_task = await self._materialize(task, payload, created_by=created_by)
|
||||
except ValueError as exc:
|
||||
return ProcessChangeResult(
|
||||
status="invalid_state", materialized_task_id=None, detail=str(exc)
|
||||
)
|
||||
process_change["status"] = "approved"
|
||||
process_change["materialized_task_id"] = str(new_task.id)
|
||||
payload["process_change"] = process_change
|
||||
markers.set_coroner_postmortem(task, payload)
|
||||
await self._record_learn(task, process_change, "approved")
|
||||
await self.session.flush()
|
||||
return ProcessChangeResult(
|
||||
status="approved",
|
||||
materialized_task_id=str(new_task.id),
|
||||
detail="materialized as a Main-PM-owned task",
|
||||
)
|
||||
|
||||
async def reject_process_change(
|
||||
self, task_id: UUID, reason: str
|
||||
) -> ProcessChangeResult | None:
|
||||
"""Dismiss the postmortem's process change, recording the CEO's
|
||||
reason.
|
||||
|
||||
Idempotent: an already-rejected change returns its stored reason
|
||||
without re-recording. An already-approved change cannot be rejected.
|
||||
A ``kind="playbook"`` change refuses outright — see
|
||||
``approve_process_change``.
|
||||
"""
|
||||
task, payload, process_change = await self._find(task_id)
|
||||
if task is None or payload is None or process_change is None:
|
||||
return None
|
||||
if process_change["kind"] == _PLAYBOOK_KIND:
|
||||
return self._playbook_result()
|
||||
if process_change["status"] == "rejected":
|
||||
return ProcessChangeResult(
|
||||
status="already_rejected",
|
||||
materialized_task_id=None,
|
||||
detail="this process change was already dismissed",
|
||||
)
|
||||
if process_change["status"] != "proposed":
|
||||
return ProcessChangeResult(
|
||||
status="invalid_state",
|
||||
materialized_task_id=process_change.get("materialized_task_id"),
|
||||
detail=(
|
||||
f"process change is {process_change['status']!r}, not "
|
||||
"proposed — cannot dismiss"
|
||||
),
|
||||
)
|
||||
process_change["status"] = "rejected"
|
||||
process_change["reject_reason"] = reason
|
||||
payload["process_change"] = process_change
|
||||
markers.set_coroner_postmortem(task, payload)
|
||||
await self._record_learn(task, process_change, "rejected", reason)
|
||||
await self.session.flush()
|
||||
return ProcessChangeResult(
|
||||
status="rejected",
|
||||
materialized_task_id=None,
|
||||
detail="dismissed; feeds the next cycle's prompt",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _playbook_result() -> ProcessChangeResult:
|
||||
return ProcessChangeResult(
|
||||
status="invalid_state",
|
||||
materialized_task_id=None,
|
||||
detail=(
|
||||
"this process change already drafted as a playbook — see the "
|
||||
"playbook review queue, there is nothing else to decide here"
|
||||
),
|
||||
)
|
||||
|
||||
async def _find(
|
||||
self, task_id: UUID
|
||||
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Resolve (exploration task, postmortem payload, the process
|
||||
change) or (None, None, None). Deep-copies the stored marker before
|
||||
mutating it — see ``RoadmapService._find_item``'s identical
|
||||
dirty-check rationale.
|
||||
|
||||
A postmortem authored before this feature shipped carries no
|
||||
``status`` key on its process change at all — ``setdefault`` treats
|
||||
it as ``proposed`` rather than crashing on a missing key.
|
||||
"""
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
task = await get_task_service(self.session).get(task_id)
|
||||
if task is None or task.source != CORONER_SOURCE:
|
||||
return None, None, None
|
||||
stored = markers.get_coroner_postmortem(task)
|
||||
if stored is None:
|
||||
return None, None, None
|
||||
payload = copy.deepcopy(stored)
|
||||
process_change = payload.get("process_change")
|
||||
if not isinstance(process_change, dict):
|
||||
return None, None, None
|
||||
process_change.setdefault("status", "proposed")
|
||||
return task, payload, process_change
|
||||
|
||||
async def _materialize(
|
||||
self,
|
||||
task: TaskTable,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
created_by: UUID,
|
||||
) -> TaskTable:
|
||||
"""Turn the approved process change into a real Main-PM-owned root
|
||||
task, anchored on the incident's own project (see module docstring
|
||||
for why). ``payload`` is the full deep-copied postmortem dict
|
||||
``_find`` already read — reused here rather than re-reading the
|
||||
task's marker column mid-transaction, before this same call's
|
||||
caller has written the approved status back.
|
||||
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
|
||||
``TaskService.approve_and_start`` — the incident's own team (resolved
|
||||
by ``_resolve_target``) is no longer the task's ``team`` column; it
|
||||
survives as a Notes delegation hint instead."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project_id, team = await self._resolve_target(task)
|
||||
if project_id is None:
|
||||
raise ValueError(
|
||||
"neither the incident's own project nor the RoboCo project "
|
||||
"(settings.self_heal_project_slug) is resolvable — cannot "
|
||||
"anchor a materialized task"
|
||||
)
|
||||
process_change = payload["process_change"]
|
||||
notes = [f"Root cause: {payload.get('root_cause', '')}".strip()]
|
||||
incident_summary = payload.get("incident_summary")
|
||||
if incident_summary:
|
||||
notes.append(f"Incident: {incident_summary}")
|
||||
notes.append(
|
||||
f"Delegation hint: the incident lived in the {team.value} cell — "
|
||||
f"delegate into the {team.value} cell."
|
||||
)
|
||||
draft = {
|
||||
"title": f"Postmortem follow-up: {process_change['description']}"[:200],
|
||||
"objective": process_change["description"],
|
||||
"notes": notes,
|
||||
"acceptance_criteria": [process_change["description"]],
|
||||
"project_id": str(project_id),
|
||||
"team": team.value,
|
||||
"priority": 2,
|
||||
"source": CORONER_ITEM_SOURCE,
|
||||
}
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
async def _resolve_target(self, task: TaskTable) -> tuple[UUID | None, Team]:
|
||||
"""(project_id, team) for the materialized follow-up — the
|
||||
incident's own, else RoboCo's project + Team.BACKEND. Mirrors
|
||||
``CoronerEngine._originate``'s ``incident.project_id or await
|
||||
self._roboco_project_id()`` exactly, extended to also resolve the
|
||||
incident's own team, which ``_materialize`` now carries forward as a
|
||||
Notes delegation hint rather than the materialized task's ``team``
|
||||
column (forced to ``Team.MAIN_PM`` — see ``_materialize``'s
|
||||
docstring)."""
|
||||
incident_ref = markers.get_coroner_incident(task) or {}
|
||||
incident_task_id = incident_ref.get("incident_task_id")
|
||||
if incident_task_id:
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
incident = await get_task_service(self.session).get(UUID(incident_task_id))
|
||||
if incident is not None and incident.project_id is not None:
|
||||
return cast("UUID", incident.project_id), incident.team or Team.BACKEND
|
||||
project = await self._roboco_project()
|
||||
if project is not None:
|
||||
return cast("UUID", project.id), Team.BACKEND
|
||||
return None, Team.BACKEND
|
||||
|
||||
async def _roboco_project(self) -> Any:
|
||||
"""Mirrors ``CoronerEngine._roboco_project_id`` exactly — the same
|
||||
fallback anchor the postmortem-exploration task itself resolves
|
||||
against when the incident carries no project."""
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
slug = (settings.self_heal_project_slug or "roboco-api").strip()
|
||||
return await get_project_service(self.session).get_by_slug(slug)
|
||||
|
||||
async def _record_learn(
|
||||
self,
|
||||
task: TaskTable,
|
||||
process_change: dict[str, Any],
|
||||
verdict: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Best-effort LEARN: a record_decision failure must never break the
|
||||
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
|
||||
|
||||
``learn_ref`` expects a ``title``/``target_task_title`` field; a
|
||||
process change carries neither, so it's wrapped with its
|
||||
``description`` under ``title`` rather than reinventing the
|
||||
truncation/fallback logic.
|
||||
"""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
await get_board_program_engine(self.session).record_decision(
|
||||
"coroner",
|
||||
learn_ref({"title": process_change.get("description")}),
|
||||
verdict,
|
||||
reason,
|
||||
exploration_task_id=cast("UUID", task.id),
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("coroner: LEARN record_decision failed (best-effort)")
|
||||
|
||||
|
||||
def get_coroner_service(session: AsyncSession) -> CoronerService:
|
||||
"""Construct a CoronerService bound to ``session``."""
|
||||
return CoronerService(session)
|
||||
@@ -6,11 +6,15 @@ the Product Owner authors the friction-fix drafts onto it via
|
||||
``propose_friction_fixes`` (1-5 evidence-backed item drafts, persisted as a
|
||||
marker payload — see ``roboco.foundation.policy.content.markers.
|
||||
get_friction_fixes``). This service is what the CEO-gated routes call:
|
||||
``approve_item`` materializes one item as a BACKLOG task (``source=dogfood``,
|
||||
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
|
||||
confirmation); ``reject_item`` records the reason. Once every item on the
|
||||
cycle is terminal (approved/rejected) the exploration task itself completes.
|
||||
Both actions are idempotent per item. Mirrors ``SpackleService`` exactly.
|
||||
``approve_item`` materializes one item as a PENDING, Main-PM-owned root task
|
||||
(``source=dogfood``, ``assigned_to=main-pm``, via ``PrompterService.
|
||||
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
|
||||
records the reason. Once every item on the cycle is terminal
|
||||
(approved/rejected) the exploration task itself completes. Both actions are
|
||||
idempotent per item. Mirrors ``SpackleService`` exactly.
|
||||
|
||||
A materialized item is NEVER an unowned BACKLOG task — see
|
||||
``RoadmapService._materialize``'s docstring for why.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,17 +22,16 @@ from __future__ import annotations
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
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.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import DOGFOOD_ITEM_SOURCE, DOGFOOD_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
@@ -176,9 +179,14 @@ class DogfoodService(BaseService):
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved item draft into a real BACKLOG task."""
|
||||
"""Turn one approved item draft into a Main-PM-owned root task.
|
||||
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
|
||||
parentless BACKLOG task and not left on the item's own cell team; the
|
||||
item's own cell survives as a Notes delegation hint instead."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await get_project_service(self.session).get_by_slug(
|
||||
item["project_slug"]
|
||||
@@ -197,7 +205,11 @@ class DogfoodService(BaseService):
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
"notes": [f"Evidence: {item['evidence']}"],
|
||||
"notes": [
|
||||
f"Evidence: {item['evidence']}",
|
||||
f"Delegation hint: originated as a {item['team']} item — "
|
||||
f"delegate into the {item['team']} cell.",
|
||||
],
|
||||
"acceptance_criteria": item["acceptance_criteria"],
|
||||
"project_id": str(project.id),
|
||||
"team": item["team"],
|
||||
@@ -207,7 +219,9 @@ class DogfoodService(BaseService):
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.BACKLOG,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
||||
|
||||
@@ -682,25 +682,39 @@ def _normalize_friction_fix_item(idx: int, raw: dict[str, Any]) -> dict[str, Any
|
||||
|
||||
def _normalize_market_brief_finding(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce a validated raw market-brief finding into the stored marker
|
||||
shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned."""
|
||||
shape. Mirrors ``_normalize_pest_hunt_item`` — ``id`` is server-assigned.
|
||||
|
||||
``status``/``reject_reason``/``materialized_task_id`` mirror the roadmap/
|
||||
pest-hunt item shape even though the exploration task itself completes
|
||||
at propose time — the finding still carries its OWN per-item CEO
|
||||
decision (``PeriscopeService.approve_finding``/``reject_finding``),
|
||||
orthogonal to the task's own terminal status.
|
||||
"""
|
||||
return {
|
||||
"id": f"finding-{idx}",
|
||||
"claim": str(raw["claim"]).strip(),
|
||||
"source_url": str(raw["source_url"]).strip(),
|
||||
"relevance": str(raw["relevance"]).strip(),
|
||||
"status": "proposed",
|
||||
"reject_reason": None,
|
||||
"materialized_task_id": None,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_quality_report_item(idx: int, raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Coerce a validated raw quality-report item into the stored marker
|
||||
shape. Mirrors ``_normalize_market_brief_finding`` — ``id`` is
|
||||
server-assigned."""
|
||||
server-assigned, and the same per-item ``status`` triple applies (see
|
||||
``SentinelService.approve_item``/``reject_item``)."""
|
||||
return {
|
||||
"id": f"item-{idx}",
|
||||
"area": str(raw["area"]).strip(),
|
||||
"observation": str(raw["observation"]).strip(),
|
||||
"evidence": str(raw["evidence"]).strip(),
|
||||
"suggested_action": str(raw["suggested_action"]).strip(),
|
||||
"status": "proposed",
|
||||
"reject_reason": None,
|
||||
"materialized_task_id": None,
|
||||
}
|
||||
|
||||
|
||||
@@ -4892,6 +4906,19 @@ class ContentActions:
|
||||
"process_change": {
|
||||
"kind": process_change["kind"],
|
||||
"description": str(process_change["description"]).strip(),
|
||||
# A "playbook" kind already routed straight into the
|
||||
# playbook curation queue above — nothing left for the
|
||||
# CEO to decide on THIS process change, so it never
|
||||
# enters the proposed/approved/rejected per-item flow
|
||||
# (CoronerService.approve_process_change/
|
||||
# reject_process_change refuse it outright).
|
||||
"status": (
|
||||
"not_applicable"
|
||||
if process_change["kind"] == "playbook"
|
||||
else "proposed"
|
||||
),
|
||||
"reject_reason": None,
|
||||
"materialized_task_id": None,
|
||||
},
|
||||
"playbook_id": playbook_id,
|
||||
},
|
||||
|
||||
@@ -6,12 +6,15 @@ the Head of Marketing authors the messaging audit onto it via
|
||||
``propose_messaging_fixes`` (1-5 evidence-backed item drafts, persisted as a
|
||||
marker payload — see ``roboco.foundation.policy.content.markers.
|
||||
get_messaging_fixes``). This service is what the CEO-gated routes call:
|
||||
``approve_item`` materializes one item as a BACKLOG docs task
|
||||
(``source=mirror``, ``task_type=documentation``, via ``PrompterService.
|
||||
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
|
||||
records the reason. Once every item on the cycle is terminal
|
||||
(approved/rejected) the exploration task itself completes. Both actions are
|
||||
idempotent per item. Mirrors ``SpackleService`` exactly.
|
||||
``approve_item`` materializes one item as a PENDING, Main-PM-owned docs root
|
||||
task (``source=mirror``, ``task_type=documentation``, ``assigned_to=main-pm``,
|
||||
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
|
||||
confirmation); ``reject_item`` records the reason. Once every item on the
|
||||
cycle is terminal (approved/rejected) the exploration task itself completes.
|
||||
Both actions are idempotent per item. Mirrors ``SpackleService`` exactly.
|
||||
|
||||
A materialized item is NEVER an unowned BACKLOG task — see
|
||||
``RoadmapService._materialize``'s docstring for why.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,17 +22,16 @@ from __future__ import annotations
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
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.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import MIRROR_ITEM_SOURCE, MIRROR_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
@@ -177,9 +179,14 @@ class MirrorService(BaseService):
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved item draft into a real BACKLOG docs task."""
|
||||
"""Turn one approved item draft into a Main-PM-owned docs root task.
|
||||
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
|
||||
parentless BACKLOG task and not left on the item's own cell team; the
|
||||
item's own cell survives as a Notes delegation hint instead."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await get_project_service(self.session).get_by_slug(
|
||||
item["project_slug"]
|
||||
@@ -197,7 +204,11 @@ class MirrorService(BaseService):
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
"notes": [f"Evidence: {item['evidence']}"],
|
||||
"notes": [
|
||||
f"Evidence: {item['evidence']}",
|
||||
f"Delegation hint: originated as a {item['team']} item — "
|
||||
f"delegate into the {item['team']} cell.",
|
||||
],
|
||||
"acceptance_criteria": item["acceptance_criteria"],
|
||||
"project_id": str(project.id),
|
||||
"team": item["team"],
|
||||
@@ -208,7 +219,9 @@ class MirrorService(BaseService):
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.BACKLOG,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"""PeriscopeService — the CEO's per-finding approve/dismiss glue over a
|
||||
completed Periscope market brief.
|
||||
|
||||
The Periscope engine opens a HELD exploration task (``board_periscope``
|
||||
source); the Head of Marketing files ONE brief onto it via
|
||||
``propose_market_brief`` (a headline + 1-7 cited findings, persisted as a
|
||||
marker payload — see
|
||||
``roboco.foundation.policy.content.markers.get_market_brief``) and the
|
||||
exploration task completes in that same call — a report, not a per-item
|
||||
queue (mirrors ``RoadmapService``'s docstring on this point exactly).
|
||||
|
||||
Unlike the exploration task, each FINDING still carries its own
|
||||
proposed/approved/rejected status the CEO decides on afterward — that is
|
||||
what this service is for. ``approve_finding`` materializes one finding as a
|
||||
PENDING, Main-PM-owned root task (``source=periscope``, ``assigned_to=
|
||||
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
|
||||
parentless BACKLOG task); ``reject_finding`` records the reason ("dismiss" —
|
||||
no task). Both are idempotent per finding.
|
||||
|
||||
A finding carries no ``project_slug`` the way a roadmap item does (Periscope
|
||||
reads the market, not a repo). The target project resolves to RoboCo's own
|
||||
project (``settings.self_heal_project_slug``, the same fallback
|
||||
``PeriscopeEngine._roboco_project`` already uses to anchor the exploration
|
||||
task itself) — a market signal is process/strategy input about the org, not
|
||||
about any one customer repo, and RoboCo is the only project every findings
|
||||
consumer has in common.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import PERISCOPE_ITEM_SOURCE, PERISCOPE_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
|
||||
_TERMINAL_ITEM_STATUSES = ("approved", "rejected")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarketBriefFindingResult:
|
||||
"""Outcome of an approve/reject call on one market-brief finding.
|
||||
|
||||
`status` is one of: approved, already_approved, rejected,
|
||||
already_rejected, invalid_state.
|
||||
"""
|
||||
|
||||
status: str
|
||||
finding_id: str
|
||||
materialized_task_id: str | None
|
||||
detail: str
|
||||
|
||||
|
||||
class PeriscopeService(BaseService):
|
||||
"""Approve / reject findings within a completed Periscope market brief."""
|
||||
|
||||
service_name = "periscope_service"
|
||||
|
||||
async def approve_finding(
|
||||
self, task_id: UUID, finding_id: str, *, created_by: UUID
|
||||
) -> MarketBriefFindingResult | None:
|
||||
"""Materialize one proposed finding as a Main-PM-owned root task.
|
||||
|
||||
Returns None when ``task_id`` carries no Periscope brief or
|
||||
``finding_id`` does not exist on it. Idempotent: an already-approved
|
||||
finding returns its stored materialized task id without creating a
|
||||
duplicate. An already-rejected finding cannot be approved.
|
||||
"""
|
||||
task, payload, finding = await self._find_finding(task_id, finding_id)
|
||||
if task is None or payload is None or finding is None:
|
||||
return None
|
||||
if finding["status"] == "approved":
|
||||
return MarketBriefFindingResult(
|
||||
status="already_approved",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=finding.get("materialized_task_id"),
|
||||
detail="this finding was already approved",
|
||||
)
|
||||
if finding["status"] != "proposed":
|
||||
return MarketBriefFindingResult(
|
||||
status="invalid_state",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=None,
|
||||
detail=(
|
||||
f"finding is {finding['status']!r}, not proposed — cannot approve"
|
||||
),
|
||||
)
|
||||
try:
|
||||
new_task = await self._materialize(finding, created_by=created_by)
|
||||
except ValueError as exc:
|
||||
return MarketBriefFindingResult(
|
||||
status="invalid_state",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=None,
|
||||
detail=str(exc),
|
||||
)
|
||||
finding["status"] = "approved"
|
||||
finding["materialized_task_id"] = str(new_task.id)
|
||||
markers.set_market_brief(task, payload)
|
||||
await self._record_learn(task, finding, "approved")
|
||||
await self.session.flush()
|
||||
return MarketBriefFindingResult(
|
||||
status="approved",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=str(new_task.id),
|
||||
detail="materialized as a Main-PM-owned task",
|
||||
)
|
||||
|
||||
async def reject_finding(
|
||||
self, task_id: UUID, finding_id: str, reason: str
|
||||
) -> MarketBriefFindingResult | None:
|
||||
"""Dismiss one proposed finding, recording the CEO's reason.
|
||||
|
||||
Idempotent: an already-rejected finding returns its stored reason
|
||||
without re-recording. An already-approved finding cannot be
|
||||
rejected (irreversible — a task already exists for it).
|
||||
"""
|
||||
task, payload, finding = await self._find_finding(task_id, finding_id)
|
||||
if task is None or payload is None or finding is None:
|
||||
return None
|
||||
if finding["status"] == "rejected":
|
||||
return MarketBriefFindingResult(
|
||||
status="already_rejected",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=None,
|
||||
detail="this finding was already dismissed",
|
||||
)
|
||||
if finding["status"] != "proposed":
|
||||
return MarketBriefFindingResult(
|
||||
status="invalid_state",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=finding.get("materialized_task_id"),
|
||||
detail=(
|
||||
f"finding is {finding['status']!r}, not proposed — cannot dismiss"
|
||||
),
|
||||
)
|
||||
finding["status"] = "rejected"
|
||||
finding["reject_reason"] = reason
|
||||
markers.set_market_brief(task, payload)
|
||||
await self._record_learn(task, finding, "rejected", reason)
|
||||
await self.session.flush()
|
||||
return MarketBriefFindingResult(
|
||||
status="rejected",
|
||||
finding_id=finding_id,
|
||||
materialized_task_id=None,
|
||||
detail="dismissed; feeds the next cycle's prompt",
|
||||
)
|
||||
|
||||
async def _find_finding(
|
||||
self, task_id: UUID, finding_id: str
|
||||
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Resolve (exploration task, brief payload, one finding) or (None,
|
||||
None, None). Deep-copies the stored marker before mutating it — see
|
||||
``RoadmapService._find_item``'s identical dirty-check rationale.
|
||||
|
||||
A finding authored before this feature shipped carries no ``status``
|
||||
key at all — ``setdefault`` treats it as ``proposed`` rather than
|
||||
crashing on a missing key.
|
||||
"""
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
task = await get_task_service(self.session).get(task_id)
|
||||
if task is None or task.source != PERISCOPE_SOURCE:
|
||||
return None, None, None
|
||||
stored = markers.get_market_brief(task)
|
||||
if stored is None:
|
||||
return None, None, None
|
||||
payload = copy.deepcopy(stored)
|
||||
finding = next(
|
||||
(f for f in payload.get("findings", []) if f.get("id") == finding_id), None
|
||||
)
|
||||
if finding is None:
|
||||
return None, None, None
|
||||
finding.setdefault("status", "proposed")
|
||||
return task, payload, finding
|
||||
|
||||
async def _materialize(
|
||||
self, finding: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved finding into a real Main-PM-owned root task,
|
||||
anchored on the RoboCo project (see module docstring for why).
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
|
||||
``TaskService.approve_and_start`` — a market signal has no natural
|
||||
owning cell, so unlike ``RoadmapService._materialize`` there is no
|
||||
per-item cell to preserve as a delegation hint."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await self._roboco_project()
|
||||
if project is None or project.id is None:
|
||||
raise ValueError(
|
||||
"the RoboCo project (settings.self_heal_project_slug) is not "
|
||||
"resolvable — cannot anchor a materialized task"
|
||||
)
|
||||
draft = {
|
||||
"title": f"Market signal: {finding['claim']}"[:200],
|
||||
"objective": finding["claim"],
|
||||
"notes": [
|
||||
f"Relevance: {finding['relevance']}",
|
||||
f"Source: {finding['source_url']}",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
f"The market signal is addressed: {finding['claim']}",
|
||||
"A note explains what changed in response and why.",
|
||||
],
|
||||
"project_id": str(project.id),
|
||||
"team": Team.BACKEND.value,
|
||||
"priority": 2,
|
||||
"source": PERISCOPE_ITEM_SOURCE,
|
||||
}
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
async def _roboco_project(self) -> Any:
|
||||
"""Mirrors ``PeriscopeEngine._roboco_project`` exactly — the same
|
||||
fallback anchor a Periscope exploration task itself resolves against."""
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
slug = (settings.self_heal_project_slug or "roboco-api").strip()
|
||||
return await get_project_service(self.session).get_by_slug(slug)
|
||||
|
||||
async def _record_learn(
|
||||
self,
|
||||
task: TaskTable,
|
||||
finding: dict[str, Any],
|
||||
verdict: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Best-effort LEARN: a record_decision failure must never break the
|
||||
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
|
||||
|
||||
``learn_ref`` expects a ``title``/``target_task_title`` field; a
|
||||
finding carries neither, so it's wrapped with its ``claim`` under
|
||||
``title`` rather than reinventing the truncation/fallback logic.
|
||||
"""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
await get_board_program_engine(self.session).record_decision(
|
||||
"periscope",
|
||||
learn_ref({"title": finding.get("claim")}),
|
||||
verdict,
|
||||
reason,
|
||||
exploration_task_id=cast("UUID", task.id),
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("periscope: LEARN record_decision failed (best-effort)")
|
||||
|
||||
|
||||
def get_periscope_service(session: AsyncSession) -> PeriscopeService:
|
||||
"""Construct a PeriscopeService bound to ``session``."""
|
||||
return PeriscopeService(session)
|
||||
@@ -6,13 +6,19 @@ source); the Product Owner authors the hunt onto it via ``propose_bug_hunt``
|
||||
(1-5 evidence-backed bug item drafts, persisted as a marker payload — see
|
||||
``roboco.foundation.policy.content.markers.get_pest_hunt``). This service is
|
||||
what the CEO-gated routes call: ``approve_item`` materializes one item as a
|
||||
BACKLOG task (``source=pest_control``, via
|
||||
``PrompterService.create_task_from_draft`` — CEO approval IS the
|
||||
confirmation); ``reject_item`` records the reason. Once every item on the
|
||||
cycle is terminal (approved/rejected) the exploration task itself completes.
|
||||
Both actions are idempotent per item. Mirrors ``RoadmapService`` exactly,
|
||||
minus the cycle-level theme goal roadmap carries (pest-hunt items stand
|
||||
alone) and with ``rationale`` replaced by the required ``evidence`` field.
|
||||
PENDING, Main-PM-owned root task (``source=pest_control``,
|
||||
``assigned_to=main-pm``, via ``PrompterService.create_task_from_draft`` — CEO
|
||||
approval IS the confirmation); ``reject_item`` records the reason. Once every
|
||||
item on the cycle is terminal (approved/rejected) the exploration task itself
|
||||
completes. Both actions are idempotent per item. Mirrors ``RoadmapService``
|
||||
exactly, minus the cycle-level theme goal roadmap carries (pest-hunt items
|
||||
stand alone) and with ``rationale`` replaced by the required ``evidence``
|
||||
field.
|
||||
|
||||
A materialized item is NEVER an unowned BACKLOG task — see
|
||||
``RoadmapService._materialize``'s docstring for why (nothing dispatches
|
||||
BACKLOG, and a parentless root a cell PM claims directly bypasses the
|
||||
Main-PM root / root->master PR / CEO approval gate).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,17 +26,16 @@ from __future__ import annotations
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
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.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import PEST_CONTROL_ITEM_SOURCE, PEST_CONTROL_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
@@ -177,9 +182,14 @@ class PestControlService(BaseService):
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved item draft into a real BACKLOG task."""
|
||||
"""Turn one approved item draft into a Main-PM-owned root task.
|
||||
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
|
||||
parentless BACKLOG task and not left on the item's own cell team; the
|
||||
item's own cell survives as a Notes delegation hint instead."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await get_project_service(self.session).get_by_slug(
|
||||
item["project_slug"]
|
||||
@@ -198,7 +208,11 @@ class PestControlService(BaseService):
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
"notes": [f"Evidence: {item['evidence']}"],
|
||||
"notes": [
|
||||
f"Evidence: {item['evidence']}",
|
||||
f"Delegation hint: originated as a {item['team']} item — "
|
||||
f"delegate into the {item['team']} cell.",
|
||||
],
|
||||
"acceptance_criteria": item["acceptance_criteria"],
|
||||
"project_id": str(project.id),
|
||||
"team": item["team"],
|
||||
@@ -208,7 +222,9 @@ class PestControlService(BaseService):
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.BACKLOG,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
||||
|
||||
@@ -76,7 +76,17 @@ _MIN_MEGATASK_PROJECTS = 2
|
||||
# "release_manager", which would even wedge the real release engine's
|
||||
# one-open-proposal dedup).
|
||||
_ALLOWED_DRAFT_SOURCES = frozenset(
|
||||
{"prompter", "roadmap", "pest_control", "spackle", "mirror", "dogfood"}
|
||||
{
|
||||
"prompter",
|
||||
"roadmap",
|
||||
"pest_control",
|
||||
"spackle",
|
||||
"mirror",
|
||||
"dogfood",
|
||||
"periscope",
|
||||
"sentinel",
|
||||
"coroner",
|
||||
}
|
||||
)
|
||||
|
||||
# A draft whose per-cell map covers at least this many cells targets the ad-hoc
|
||||
@@ -110,6 +120,11 @@ class BatchPlacement:
|
||||
owning team for the whole batch; ``parent_task_id`` is the umbrella (or None
|
||||
for the umbrella itself); ``batch_id`` is the shared batch identity; and
|
||||
``sequence`` is the item's wave index.
|
||||
|
||||
``team_override`` alone (the other three left at their batch-less
|
||||
defaults) is also the seam Board Program materialization uses — e.g.
|
||||
``RoadmapService._materialize`` — to force ``team=Team.MAIN_PM`` on a
|
||||
materialized coordination root without it being part of any real batch.
|
||||
"""
|
||||
|
||||
parent_task_id: UUID | None = None
|
||||
|
||||
@@ -6,10 +6,17 @@ the Product Owner authors the cycle onto it via ``propose_roadmap`` (a goal +
|
||||
3-7 item drafts, persisted as a marker payload — see
|
||||
``roboco.foundation.policy.content.markers.get_roadmap_cycle``). This service
|
||||
is what the CEO-gated routes call: ``approve_item`` materializes one item as a
|
||||
BACKLOG task (``source=roadmap``, via ``PrompterService.create_task_from_draft``
|
||||
— CEO approval IS the confirmation); ``reject_item`` records the reason. Once
|
||||
every item on the cycle is terminal (approved/rejected) the exploration task
|
||||
itself completes. Both actions are idempotent per item.
|
||||
PENDING, Main-PM-owned root task (``source=roadmap``, ``assigned_to=main-pm``,
|
||||
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
|
||||
confirmation); ``reject_item`` records the reason. Once every item on the
|
||||
cycle is terminal (approved/rejected) the exploration task itself completes.
|
||||
Both actions are idempotent per item.
|
||||
|
||||
A materialized item is NEVER an unowned BACKLOG task: nothing dispatches
|
||||
BACKLOG, and a parentless root a cell PM claims directly would resolve its
|
||||
own merge target straight to the project's head rung on ``complete()``,
|
||||
bypassing the Main-PM root, the root->master PR, and the CEO's approval gate
|
||||
— see ``_materialize``'s docstring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,17 +24,16 @@ from __future__ import annotations
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
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.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import ROADMAP_ITEM_SOURCE, ROADMAP_SOURCE, get_task_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
@@ -177,9 +183,28 @@ class RoadmapService(BaseService):
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved item draft into a real BACKLOG task."""
|
||||
"""Turn one approved item draft into a Main-PM-owned root task.
|
||||
|
||||
PENDING + ``assigned_to=main-pm`` — NOT a parentless BACKLOG task.
|
||||
Nothing dispatches BACKLOG, and a parentless task a cell PM claims
|
||||
and completes resolves its merge target (``resolve_parent_branch``)
|
||||
straight to the project's head rung, bypassing the Main-PM root, the
|
||||
root->master PR, and the CEO's approval gate (live proof: PRs
|
||||
#703/#704 merged feature/{team}/... -> slave directly). Pre-assigning
|
||||
the Main PM makes this a real coordination root that dispatches
|
||||
immediately — ``team=Team.MAIN_PM`` (via ``BatchPlacement``'s
|
||||
``team_override`` seam, the same knob a MegaTask batch uses), matching
|
||||
``TaskService.approve_and_start`` exactly, since every "is this a
|
||||
coordination root" check (``pr_fail``'s next-hint, the PR-gate steer,
|
||||
delegate's wave-chain branch, the PR labeler) keys on ``team``, not
|
||||
``assigned_to``. The item's own cell survives as a delegation hint in
|
||||
the task's Notes (part of the composed description, which the Main
|
||||
PM's spawn briefing renders verbatim) rather than in the ``team``
|
||||
column — the same shape intake's "Approve & Start" produces.
|
||||
"""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await get_project_service(self.session).get_by_slug(
|
||||
item["project_slug"]
|
||||
@@ -197,7 +222,11 @@ class RoadmapService(BaseService):
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
"notes": [f"Rationale: {item['rationale']}"],
|
||||
"notes": [
|
||||
f"Rationale: {item['rationale']}",
|
||||
f"Delegation hint: originated as a {item['team']} item — "
|
||||
f"delegate into the {item['team']} cell.",
|
||||
],
|
||||
"acceptance_criteria": item["acceptance_criteria"],
|
||||
"project_id": str(project.id),
|
||||
"team": item["team"],
|
||||
@@ -207,7 +236,9 @@ class RoadmapService(BaseService):
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.BACKLOG,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""SentinelService — the CEO's per-item approve/dismiss glue over a
|
||||
completed Sentinel quality report.
|
||||
|
||||
The Sentinel engine opens a HELD exploration task (``board_sentinel``
|
||||
source); the Auditor files ONE "state of quality" report onto it via
|
||||
``propose_quality_report`` (a headline + 1-7 drift items, persisted as a
|
||||
marker payload — see
|
||||
``roboco.foundation.policy.content.markers.get_quality_report``) and the
|
||||
exploration task completes in that same call — a report, not a per-item
|
||||
queue.
|
||||
|
||||
Unlike the exploration task, each ITEM still carries its own
|
||||
proposed/approved/rejected status the CEO decides on afterward — that is
|
||||
what this service is for. ``approve_item`` materializes one item as a
|
||||
PENDING, Main-PM-owned root task (``source=sentinel``, ``assigned_to=
|
||||
main-pm`` — see ``RoadmapService._materialize``'s docstring for why never a
|
||||
parentless BACKLOG task); ``reject_item`` records the reason ("dismiss" —
|
||||
no task). Both are idempotent per item. Mirrors ``PeriscopeService`` closely
|
||||
— a Sentinel drift item already carries a machine-readable
|
||||
``suggested_action``, used directly as the materialized task's acceptance
|
||||
criterion.
|
||||
|
||||
A drift item carries no ``project_slug`` the way a roadmap item does
|
||||
(Sentinel watches the org's own process — waivers, findings, conventions,
|
||||
budget, docs drift — not one repo). The target project resolves to RoboCo's
|
||||
own project (``settings.self_heal_project_slug``, the same fallback
|
||||
``SentinelEngine._roboco_project`` already uses to anchor the exploration
|
||||
task itself) — drift in RoboCo's own delivery process is, definitionally,
|
||||
about RoboCo's own project.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.models.base import TaskStatus, TaskType, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import SENTINEL_ITEM_SOURCE, SENTINEL_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
|
||||
# Sentinel items tagged "docs" materialize as documentation tasks — mirrors
|
||||
# MirrorService's identical area->task_type override.
|
||||
_DOCS_AREA = "docs"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityReportItemResult:
|
||||
"""Outcome of an approve/reject call on one quality-report item.
|
||||
|
||||
`status` is one of: approved, already_approved, rejected,
|
||||
already_rejected, invalid_state.
|
||||
"""
|
||||
|
||||
status: str
|
||||
item_id: str
|
||||
materialized_task_id: str | None
|
||||
detail: str
|
||||
|
||||
|
||||
class SentinelService(BaseService):
|
||||
"""Approve / reject items within a completed Sentinel quality report."""
|
||||
|
||||
service_name = "sentinel_service"
|
||||
|
||||
async def approve_item(
|
||||
self, task_id: UUID, item_id: str, *, created_by: UUID
|
||||
) -> QualityReportItemResult | None:
|
||||
"""Materialize one proposed drift item as a Main-PM-owned root task.
|
||||
|
||||
Returns None when ``task_id`` carries no Sentinel report or
|
||||
``item_id`` does not exist on it. Idempotent: an already-approved
|
||||
item returns its stored materialized task id without creating a
|
||||
duplicate. An already-rejected item cannot be approved.
|
||||
"""
|
||||
task, payload, item = await self._find_item(task_id, item_id)
|
||||
if task is None or payload is None or item is None:
|
||||
return None
|
||||
if item["status"] == "approved":
|
||||
return QualityReportItemResult(
|
||||
status="already_approved",
|
||||
item_id=item_id,
|
||||
materialized_task_id=item.get("materialized_task_id"),
|
||||
detail="this item was already approved",
|
||||
)
|
||||
if item["status"] != "proposed":
|
||||
return QualityReportItemResult(
|
||||
status="invalid_state",
|
||||
item_id=item_id,
|
||||
materialized_task_id=None,
|
||||
detail=f"item is {item['status']!r}, not proposed — cannot approve",
|
||||
)
|
||||
try:
|
||||
new_task = await self._materialize(item, created_by=created_by)
|
||||
except ValueError as exc:
|
||||
return QualityReportItemResult(
|
||||
status="invalid_state",
|
||||
item_id=item_id,
|
||||
materialized_task_id=None,
|
||||
detail=str(exc),
|
||||
)
|
||||
item["status"] = "approved"
|
||||
item["materialized_task_id"] = str(new_task.id)
|
||||
markers.set_quality_report(task, payload)
|
||||
await self._record_learn(task, item, "approved")
|
||||
await self.session.flush()
|
||||
return QualityReportItemResult(
|
||||
status="approved",
|
||||
item_id=item_id,
|
||||
materialized_task_id=str(new_task.id),
|
||||
detail="materialized as a Main-PM-owned task",
|
||||
)
|
||||
|
||||
async def reject_item(
|
||||
self, task_id: UUID, item_id: str, reason: str
|
||||
) -> QualityReportItemResult | None:
|
||||
"""Dismiss one proposed drift item, recording the CEO's reason.
|
||||
|
||||
Idempotent: an already-rejected item returns its stored reason
|
||||
without re-recording. An already-approved item cannot be rejected
|
||||
(irreversible — a task already exists for it).
|
||||
"""
|
||||
task, payload, item = await self._find_item(task_id, item_id)
|
||||
if task is None or payload is None or item is None:
|
||||
return None
|
||||
if item["status"] == "rejected":
|
||||
return QualityReportItemResult(
|
||||
status="already_rejected",
|
||||
item_id=item_id,
|
||||
materialized_task_id=None,
|
||||
detail="this item was already dismissed",
|
||||
)
|
||||
if item["status"] != "proposed":
|
||||
return QualityReportItemResult(
|
||||
status="invalid_state",
|
||||
item_id=item_id,
|
||||
materialized_task_id=item.get("materialized_task_id"),
|
||||
detail=f"item is {item['status']!r}, not proposed — cannot dismiss",
|
||||
)
|
||||
item["status"] = "rejected"
|
||||
item["reject_reason"] = reason
|
||||
markers.set_quality_report(task, payload)
|
||||
await self._record_learn(task, item, "rejected", reason)
|
||||
await self.session.flush()
|
||||
return QualityReportItemResult(
|
||||
status="rejected",
|
||||
item_id=item_id,
|
||||
materialized_task_id=None,
|
||||
detail="dismissed; feeds the next cycle's prompt",
|
||||
)
|
||||
|
||||
async def _find_item(
|
||||
self, task_id: UUID, item_id: str
|
||||
) -> tuple[TaskTable | None, dict[str, Any] | None, dict[str, Any] | None]:
|
||||
"""Resolve (exploration task, report payload, one item) or (None,
|
||||
None, None). Deep-copies the stored marker before mutating it — see
|
||||
``RoadmapService._find_item``'s identical dirty-check rationale.
|
||||
|
||||
An item authored before this feature shipped carries no ``status``
|
||||
key at all — ``setdefault`` treats it as ``proposed`` rather than
|
||||
crashing on a missing key.
|
||||
"""
|
||||
from roboco.services.task import get_task_service
|
||||
|
||||
task = await get_task_service(self.session).get(task_id)
|
||||
if task is None or task.source != SENTINEL_SOURCE:
|
||||
return None, None, None
|
||||
stored = markers.get_quality_report(task)
|
||||
if stored is None:
|
||||
return None, None, None
|
||||
payload = copy.deepcopy(stored)
|
||||
item = next(
|
||||
(it for it in payload.get("items", []) if it.get("id") == item_id), None
|
||||
)
|
||||
if item is None:
|
||||
return None, None, None
|
||||
item.setdefault("status", "proposed")
|
||||
return task, payload, item
|
||||
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved drift item into a real Main-PM-owned root task,
|
||||
anchored on the RoboCo project (see module docstring for why).
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), matching
|
||||
``TaskService.approve_and_start`` — a process/quality drift item has no
|
||||
natural owning cell, so unlike ``RoadmapService._materialize`` there is
|
||||
no per-item cell to preserve as a delegation hint."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await self._roboco_project()
|
||||
if project is None or project.id is None:
|
||||
raise ValueError(
|
||||
"the RoboCo project (settings.self_heal_project_slug) is not "
|
||||
"resolvable — cannot anchor a materialized task"
|
||||
)
|
||||
draft = {
|
||||
"title": f"Sentinel [{item['area']}]: {item['suggested_action']}"[:200],
|
||||
"objective": item["suggested_action"],
|
||||
"notes": [
|
||||
f"Observation: {item['observation']}",
|
||||
f"Evidence: {item['evidence']}",
|
||||
],
|
||||
"acceptance_criteria": [
|
||||
item["suggested_action"],
|
||||
f"Addresses the drift observed: {item['observation']}",
|
||||
],
|
||||
"project_id": str(project.id),
|
||||
"team": Team.BACKEND.value,
|
||||
"priority": 2,
|
||||
"source": SENTINEL_ITEM_SOURCE,
|
||||
}
|
||||
if item.get("area") == _DOCS_AREA:
|
||||
draft["task_type"] = TaskType.DOCUMENTATION.value
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
async def _roboco_project(self) -> Any:
|
||||
"""Mirrors ``SentinelEngine._roboco_project`` exactly — the same
|
||||
fallback anchor a Sentinel exploration task itself resolves against."""
|
||||
from roboco.services.project import get_project_service
|
||||
|
||||
slug = (settings.self_heal_project_slug or "roboco-api").strip()
|
||||
return await get_project_service(self.session).get_by_slug(slug)
|
||||
|
||||
async def _record_learn(
|
||||
self,
|
||||
task: TaskTable,
|
||||
item: dict[str, Any],
|
||||
verdict: str,
|
||||
reason: str | None = None,
|
||||
) -> None:
|
||||
"""Best-effort LEARN: a record_decision failure must never break the
|
||||
CEO's approve/reject — mirrors ``RoadmapService._record_learn``.
|
||||
|
||||
``learn_ref`` expects a ``title``/``target_task_title`` field; a
|
||||
drift item carries neither, so it's wrapped with its
|
||||
``suggested_action`` under ``title`` rather than reinventing the
|
||||
truncation/fallback logic.
|
||||
"""
|
||||
try:
|
||||
from roboco.services.board_programs import get_board_program_engine
|
||||
|
||||
await get_board_program_engine(self.session).record_decision(
|
||||
"sentinel",
|
||||
learn_ref({"title": item.get("suggested_action")}),
|
||||
verdict,
|
||||
reason,
|
||||
exploration_task_id=cast("UUID", task.id),
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("sentinel: LEARN record_decision failed (best-effort)")
|
||||
|
||||
|
||||
def get_sentinel_service(session: AsyncSession) -> SentinelService:
|
||||
"""Construct a SentinelService bound to ``session``."""
|
||||
return SentinelService(session)
|
||||
@@ -6,11 +6,14 @@ the Product Owner authors the gap-fill audit onto it via ``propose_gap_fill``
|
||||
(1-5 evidence-backed item drafts, persisted as a marker payload — see
|
||||
``roboco.foundation.policy.content.markers.get_gap_fill``). This service is
|
||||
what the CEO-gated routes call: ``approve_item`` materializes one item as a
|
||||
BACKLOG task (``source=spackle``, via ``PrompterService.
|
||||
create_task_from_draft`` — CEO approval IS the confirmation); ``reject_item``
|
||||
records the reason. Once every item on the cycle is terminal
|
||||
(approved/rejected) the exploration task itself completes. Both actions are
|
||||
idempotent per item. Mirrors ``PestControlService`` exactly.
|
||||
PENDING, Main-PM-owned root task (``source=spackle``, ``assigned_to=main-pm``,
|
||||
via ``PrompterService.create_task_from_draft`` — CEO approval IS the
|
||||
confirmation); ``reject_item`` records the reason. Once every item on the
|
||||
cycle is terminal (approved/rejected) the exploration task itself completes.
|
||||
Both actions are idempotent per item. Mirrors ``PestControlService`` exactly.
|
||||
|
||||
A materialized item is NEVER an unowned BACKLOG task — see
|
||||
``RoadmapService._materialize``'s docstring for why.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,17 +21,16 @@ from __future__ import annotations
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
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.models.base import TaskStatus, Team
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.services.board_programs import learn_ref
|
||||
from roboco.services.task import SPACKLE_ITEM_SOURCE, SPACKLE_SOURCE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.db.tables import TaskTable
|
||||
@@ -175,9 +177,14 @@ class SpackleService(BaseService):
|
||||
async def _materialize(
|
||||
self, item: dict[str, Any], *, created_by: UUID
|
||||
) -> TaskTable:
|
||||
"""Turn one approved item draft into a real BACKLOG task."""
|
||||
"""Turn one approved item draft into a Main-PM-owned root task.
|
||||
Mirrors ``RoadmapService._materialize`` — PENDING + main-pm and
|
||||
``team=Team.MAIN_PM`` (via ``BatchPlacement.team_override``), not a
|
||||
parentless BACKLOG task and not left on the item's own cell team; the
|
||||
item's own cell survives as a Notes delegation hint instead."""
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.project import get_project_service
|
||||
from roboco.services.prompter import get_prompter_service
|
||||
from roboco.services.prompter import BatchPlacement, get_prompter_service
|
||||
|
||||
project = await get_project_service(self.session).get_by_slug(
|
||||
item["project_slug"]
|
||||
@@ -196,7 +203,11 @@ class SpackleService(BaseService):
|
||||
draft = {
|
||||
"title": item["title"],
|
||||
"objective": item["description"],
|
||||
"notes": [f"Evidence: {item['evidence']}"],
|
||||
"notes": [
|
||||
f"Evidence: {item['evidence']}",
|
||||
f"Delegation hint: originated as a {item['team']} item — "
|
||||
f"delegate into the {item['team']} cell.",
|
||||
],
|
||||
"acceptance_criteria": item["acceptance_criteria"],
|
||||
"project_id": str(project.id),
|
||||
"team": item["team"],
|
||||
@@ -206,7 +217,9 @@ class SpackleService(BaseService):
|
||||
return await get_prompter_service(self.session).create_task_from_draft(
|
||||
draft,
|
||||
created_by,
|
||||
status=TaskStatus.BACKLOG,
|
||||
status=TaskStatus.PENDING,
|
||||
assigned_to=UUID(AGENT_UUIDS["main-pm"]),
|
||||
placement=BatchPlacement(team_override=Team.MAIN_PM),
|
||||
)
|
||||
|
||||
def _maybe_complete_cycle(self, task: TaskTable, payload: dict[str, Any]) -> None:
|
||||
|
||||
+33
-9
@@ -746,20 +746,38 @@ PEST_CONTROL_ITEM_SOURCE = "pest_control"
|
||||
# (competitors, adjacent-tool releases, positioning shifts) and author a brief
|
||||
# via the ``propose_market_brief`` content verb. Org-scoped (no project
|
||||
# targeting — it reads the market, not a repo) and, like X_FEATURE_
|
||||
# EXPLORATION_SOURCE, complete-at-propose: a report has no per-item CEO
|
||||
# decision, so no separate materialized-item source exists for it.
|
||||
# EXPLORATION_SOURCE, complete-at-propose: the exploration task itself
|
||||
# completes the moment the brief is filed, but each cited finding still
|
||||
# carries its OWN proposed/approved/rejected status the CEO decides
|
||||
# per-finding after the fact (source=PERISCOPE_ITEM_SOURCE below) — the report
|
||||
# and the per-item queue are orthogonal, unlike roadmap/pest-control where
|
||||
# they're the same open-vs-closed task.
|
||||
PERISCOPE_SOURCE = "board_periscope"
|
||||
|
||||
# Source tag stamped on a task MATERIALIZED from an approved Periscope
|
||||
# finding (distinct from PERISCOPE_SOURCE, which tags the already-completed
|
||||
# exploration task the finding lives on).
|
||||
PERISCOPE_ITEM_SOURCE = "periscope"
|
||||
|
||||
# Source tag for a Coroner (Board Program) postmortem-exploration task: the
|
||||
# EVENT-triggered autopsy the Auditor authors (spec §4) when an incident task
|
||||
# bounces >=3x, is cancelled after work started, or is budget-blocked.
|
||||
# Dispatched (one-shot Auditor spawn), never rides the delivery lifecycle, and
|
||||
# — unlike ROADMAP_SOURCE/PEST_CONTROL_SOURCE — has no separate materialized-
|
||||
# item source: a single ``propose_postmortem`` call completes it in place
|
||||
# (mirrors X_FEATURE_EXPLORATION_SOURCE's atomic-complete shape, not the
|
||||
# stays-open-for-per-item-decisions roadmap/pest-control shape).
|
||||
# Dispatched (one-shot Auditor spawn), never rides the delivery lifecycle.
|
||||
# The exploration task completes atomically the moment ``propose_postmortem``
|
||||
# lands (mirrors X_FEATURE_EXPLORATION_SOURCE's atomic-complete shape, not the
|
||||
# stays-open-for-per-item-decisions roadmap/pest-control shape) — but unlike
|
||||
# a report, a postmortem's single ``process_change`` still carries its own
|
||||
# proposed/approved/rejected status for the CEO's after-the-fact decision
|
||||
# (source=CORONER_ITEM_SOURCE below), except when its kind is "playbook"
|
||||
# (already routed straight into the playbook curation queue, nothing left to
|
||||
# decide here).
|
||||
CORONER_SOURCE = "board_coroner"
|
||||
|
||||
# Source tag stamped on a task MATERIALIZED from an approved Coroner
|
||||
# process-change (distinct from CORONER_SOURCE, which tags the
|
||||
# already-completed postmortem exploration task it lives on).
|
||||
CORONER_ITEM_SOURCE = "coroner"
|
||||
|
||||
# Source tag for a Scales (Board Program) portfolio-rebalance exploration
|
||||
# cycle: a PENDING task the scales engine opens for the Product Owner to
|
||||
# review the live backlog against the charter and author re-priority /
|
||||
@@ -822,10 +840,16 @@ async def _fire_coroner_bounce_hook(task_id: UUID) -> None:
|
||||
# (waiver-accumulation trends, conventions-violation hotspots, docs/map
|
||||
# staleness, budget anomalies) and file ONE "state of quality" report via the
|
||||
# ``propose_quality_report`` content verb. Org-scoped (no project targeting)
|
||||
# and, like PERISCOPE_SOURCE, complete-at-propose: a report has no per-item
|
||||
# CEO decision, so no separate materialized-item source exists for it.
|
||||
# and, like PERISCOPE_SOURCE, complete-at-propose — but each drift item still
|
||||
# carries its own proposed/approved/rejected status for the CEO's per-item
|
||||
# decision after the report is filed (source=SENTINEL_ITEM_SOURCE below).
|
||||
SENTINEL_SOURCE = "board_sentinel"
|
||||
|
||||
# Source tag stamped on a task MATERIALIZED from an approved Sentinel drift
|
||||
# item (distinct from SENTINEL_SOURCE, which tags the already-completed
|
||||
# exploration task the item lives on).
|
||||
SENTINEL_ITEM_SOURCE = "sentinel"
|
||||
|
||||
# Source tag for a Spackle (Board Program) exploration cycle: a PENDING task
|
||||
# the spackle engine opens for the Product Owner to audit an opted-in
|
||||
# project's half-shipped surface area (API routes with no panel surface and
|
||||
|
||||
+15
-3
@@ -40,14 +40,17 @@ def seed_company(stack: E2EStack) -> Company:
|
||||
return _COMPANY_CACHE["company"]
|
||||
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
|
||||
out = Company()
|
||||
|
||||
async def _run(session: AsyncSession) -> None:
|
||||
def agent(slug: str, role: AgentRole, team: Team | None) -> AgentTable:
|
||||
def agent(
|
||||
slug: str, role: AgentRole, team: Team | None, *, agent_id: Any = None
|
||||
) -> AgentTable:
|
||||
row = AgentTable(
|
||||
id=uuid4(),
|
||||
id=agent_id or uuid4(),
|
||||
name=slug,
|
||||
slug=slug,
|
||||
role=role,
|
||||
@@ -66,7 +69,16 @@ def seed_company(stack: E2EStack) -> Company:
|
||||
qa = agent("be-qa", AgentRole.QA, Team.BACKEND)
|
||||
doc = agent("be-doc", AgentRole.DOCUMENTER, Team.BACKEND)
|
||||
cell_pm = agent("be-pm", AgentRole.CELL_PM, Team.BACKEND)
|
||||
main_pm = agent("main-pm", AgentRole.MAIN_PM, None)
|
||||
# The canonical fixed UUID, not a random one: RoadmapService et al.'s
|
||||
# per-item materialize (and the MegaTask main_pm-route batch confirm)
|
||||
# hardcode AGENT_UUIDS["main-pm"] as the owning assignee, an FK to a
|
||||
# real agents row — the same identity production seeding uses.
|
||||
main_pm = agent(
|
||||
"main-pm",
|
||||
AgentRole.MAIN_PM,
|
||||
None,
|
||||
agent_id=_foundation.AGENTS["main-pm"].uuid,
|
||||
)
|
||||
reviewer = agent("pr-reviewer-1", AgentRole.PR_REVIEWER, None)
|
||||
ceo = agent("ceo", AgentRole.CEO, None)
|
||||
hom = agent("head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD)
|
||||
|
||||
@@ -57,6 +57,12 @@ def _seed_system_hom_ceo_and_project(stack: E2EStack) -> str:
|
||||
Team.BOARD,
|
||||
),
|
||||
(_foundation.AGENTS["ceo"].uuid, "ceo", AgentRole.CEO, None),
|
||||
(
|
||||
_foundation.AGENTS["main-pm"].uuid,
|
||||
"main-pm",
|
||||
AgentRole.MAIN_PM,
|
||||
Team.MAIN_PM,
|
||||
),
|
||||
):
|
||||
if await session.get(AgentTable, agent_uuid) is not None:
|
||||
continue
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import update
|
||||
_SEED_GIT_URL = "https://example.com/roboco-api-dogfood.git"
|
||||
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_main_pm(session: AsyncSession) -> None:
|
||||
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
||||
assigns this id to the materialized task (an FK to ``agents``). The
|
||||
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
||||
other seed helpers here use): ``TaskService.approve_and_start`` and
|
||||
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
||||
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
||||
here would permanently squat that id with an unresolvable row for every
|
||||
other test in the shared suite run."""
|
||||
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
||||
return
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=MAIN_PM_UUID,
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
||||
await _seed_ceo(session)
|
||||
await _seed_main_pm(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo API",
|
||||
@@ -192,9 +223,10 @@ async def test_list_cycles_returns_authored_cycle(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_item_materializes_backlog_task(
|
||||
async def test_approve_item_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
|
||||
task, _project = await _seed_cycle(db_session)
|
||||
resp = await ceo_client.post(f"/api/dogfood/cycles/{task.id}/items/item-0/approve")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
|
||||
|
||||
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
||||
assert materialized is not None
|
||||
assert materialized.status == TaskStatus.BACKLOG
|
||||
assert materialized.status == TaskStatus.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -35,6 +35,7 @@ from roboco.models.base import (
|
||||
TaskType,
|
||||
Team,
|
||||
)
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
@@ -825,8 +826,16 @@ async def test_pm_escalate_to_ceo_path(
|
||||
project = lifecycle_setup["project"]
|
||||
system_agent = lifecycle_setup["system_agent"]
|
||||
|
||||
main_pm_agent = AgentTable(
|
||||
id=uuid4(),
|
||||
# Keyed on the fixed foundation UUID + an existence check (mirrors
|
||||
# test_task_service_transitions.py's identical seeding pattern): the
|
||||
# cross-test-shared DB already has other tests seeding this exact
|
||||
# "main-pm" slug, and an unconditional insert with a fresh random id
|
||||
# would collide on the slug's unique index.
|
||||
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
|
||||
if await db_session.get(AgentTable, main_pm_id) is None:
|
||||
db_session.add(
|
||||
AgentTable(
|
||||
id=main_pm_id,
|
||||
name="Main PM",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
@@ -838,7 +847,7 @@ async def test_pm_escalate_to_ceo_path(
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(main_pm_agent)
|
||||
)
|
||||
await db_session.flush()
|
||||
del project, system_agent # only needed for fixture wiring above.
|
||||
|
||||
@@ -849,7 +858,7 @@ async def test_pm_escalate_to_ceo_path(
|
||||
task.qa_verified = True
|
||||
task.docs_complete = True
|
||||
task.parent_task_id = None # explicit — escalate_to_ceo refuses subtasks.
|
||||
task.assigned_to = main_pm_agent.id
|
||||
task.assigned_to = main_pm_id
|
||||
task.commits = [
|
||||
{"sha": uuid4().hex[:40], "message": "feat: /healthz", "task_id": str(task.id)}
|
||||
]
|
||||
@@ -862,7 +871,7 @@ async def test_pm_escalate_to_ceo_path(
|
||||
# cast for mypy under the project's strict config — the values are
|
||||
# already real ``uuid.UUID`` at runtime.
|
||||
env = await c.complete(
|
||||
UUID(str(main_pm_agent.id)),
|
||||
main_pm_id,
|
||||
UUID(str(task.id)),
|
||||
notes="Root task ready for CEO approval — escalating.",
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import update
|
||||
_SEED_GIT_URL = "https://example.com/backend-svc-mirror.git"
|
||||
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_main_pm(session: AsyncSession) -> None:
|
||||
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
||||
assigns this id to the materialized task (an FK to ``agents``). The
|
||||
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
||||
other seed helpers here use): ``TaskService.approve_and_start`` and
|
||||
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
||||
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
||||
here would permanently squat that id with an unresolvable row for every
|
||||
other test in the shared suite run."""
|
||||
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
||||
return
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=MAIN_PM_UUID,
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
hom = await _seed_agent(session, AgentRole.HEAD_MARKETING, "head-marketing")
|
||||
await _seed_ceo(session)
|
||||
await _seed_main_pm(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="Backend Service",
|
||||
@@ -192,9 +223,12 @@ async def test_list_cycles_returns_authored_cycle(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_item_materializes_backlog_task(
|
||||
async def test_approve_item_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""Defect fix: see test_roadmap_routes.py's identical assertion update.
|
||||
Documentation type is untouched by the main-pm code->planning coercion
|
||||
(that only retypes ``code``, never ``documentation``)."""
|
||||
task, _project = await _seed_cycle(db_session)
|
||||
resp = await ceo_client.post(f"/api/mirror/cycles/{task.id}/items/item-0/approve")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
@@ -204,7 +238,9 @@ async def test_approve_item_materializes_backlog_task(
|
||||
|
||||
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
||||
assert materialized is not None
|
||||
assert materialized.status == TaskStatus.BACKLOG
|
||||
assert materialized.status == TaskStatus.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.task_type == TaskType.DOCUMENTATION
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import update
|
||||
_SEED_GIT_URL = "https://example.com/backend-svc.git"
|
||||
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_main_pm(session: AsyncSession) -> None:
|
||||
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
||||
assigns this id to the materialized task (an FK to ``agents``). The
|
||||
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
||||
other seed helpers here use): ``TaskService.approve_and_start`` and
|
||||
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
||||
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
||||
here would permanently squat that id with an unresolvable row for every
|
||||
other test in the shared suite run."""
|
||||
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
||||
return
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=MAIN_PM_UUID,
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
||||
await _seed_ceo(session)
|
||||
await _seed_main_pm(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="Backend Service",
|
||||
@@ -190,9 +221,10 @@ async def test_list_cycles_returns_authored_cycle(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_item_materializes_backlog_task(
|
||||
async def test_approve_item_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
|
||||
task, _project = await _seed_cycle(db_session)
|
||||
resp = await ceo_client.post(
|
||||
f"/api/pest-control/cycles/{task.id}/items/item-0/approve"
|
||||
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
|
||||
|
||||
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
||||
assert materialized is not None
|
||||
assert materialized.status == TaskStatus.BACKLOG
|
||||
assert materialized.status == TaskStatus.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -2,13 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import AgentTable, ProjectTable, TaskTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.base import TaskNature, TaskStatus, TaskType
|
||||
from roboco.seeds.initial_data import AGENT_UUIDS
|
||||
from roboco.services.base import NotFoundError
|
||||
from roboco.services.prompter import (
|
||||
PrompterService,
|
||||
@@ -138,9 +139,28 @@ async def redraft_setup(db_session: AsyncSession) -> AsyncIterator[dict]:
|
||||
metrics={},
|
||||
)
|
||||
|
||||
main_pm = _agent("main-pm", AgentRole.MAIN_PM)
|
||||
# merge() with the fixed AGENT_UUIDS id: idempotent whether or not another
|
||||
# test already committed this exact "main-pm"-slugged row on the shared
|
||||
# session-scoped test DB (mirrors test_prompter.py's identical upsert) —
|
||||
# an unconditional insert with a fresh random id here would collide with
|
||||
# any other test's row on the slug's unique index.
|
||||
main_pm = await db_session.merge(
|
||||
AgentTable(
|
||||
id=UUID(AGENT_UUIDS["main-pm"]),
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
po = _agent(f"product-owner-{uuid4().hex[:4]}", AgentRole.PRODUCT_OWNER)
|
||||
db_session.add_all([main_pm, po])
|
||||
db_session.add(po)
|
||||
await db_session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
|
||||
@@ -21,6 +21,7 @@ from roboco.models.permissions import AgentContext
|
||||
from roboco.services.task import ROADMAP_SOURCE
|
||||
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -70,10 +71,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_main_pm(session: AsyncSession) -> None:
|
||||
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
||||
assigns this id to the materialized task (an FK to ``agents``). The
|
||||
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
||||
other seed helpers here use): ``TaskService.approve_and_start`` and
|
||||
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
||||
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
||||
here would permanently squat that id with an unresolvable row for every
|
||||
other test in the shared suite run."""
|
||||
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
||||
return
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=MAIN_PM_UUID,
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
||||
await _seed_ceo(session)
|
||||
await _seed_main_pm(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="Backend Service",
|
||||
@@ -170,9 +201,13 @@ async def test_list_cycles_returns_authored_cycle(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_item_materializes_backlog_task(
|
||||
async def test_approve_item_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""Defect fix (#703/#704): approval used to materialize an unowned
|
||||
BACKLOG task; see test_roadmap_service.py's identical assertion update
|
||||
for the full rationale. It now materializes PENDING + assigned_to=
|
||||
main-pm."""
|
||||
task, _project = await _seed_cycle(db_session)
|
||||
resp = await ceo_client.post(f"/api/roadmap/cycles/{task.id}/items/item-0/approve")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
@@ -182,7 +217,9 @@ async def test_approve_item_materializes_backlog_task(
|
||||
|
||||
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
||||
assert materialized is not None
|
||||
assert materialized.status == TaskStatus.BACKLOG
|
||||
assert materialized.status == TaskStatus.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -25,6 +25,7 @@ from sqlalchemy import update
|
||||
_SEED_GIT_URL = "https://example.com/backend-svc-spackle.git"
|
||||
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -74,10 +75,40 @@ async def _seed_ceo(session: AsyncSession) -> None:
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_main_pm(session: AsyncSession) -> None:
|
||||
"""The Main PM row matching ``MAIN_PM_UUID`` — approving an item now
|
||||
assigns this id to the materialized task (an FK to ``agents``). The
|
||||
slug must be the exact ``"main-pm"`` (not a randomized suffix like the
|
||||
other seed helpers here use): ``TaskService.approve_and_start`` and
|
||||
other call sites resolve the Main PM by an EXACT slug lookup, and this
|
||||
row's id is the fixed, cross-test-shared foundation UUID — a wrong slug
|
||||
here would permanently squat that id with an unresolvable row for every
|
||||
other test in the shared suite run."""
|
||||
if await session.get(AgentTable, MAIN_PM_UUID) is not None:
|
||||
return
|
||||
session.add(
|
||||
AgentTable(
|
||||
id=MAIN_PM_UUID,
|
||||
name="main-pm",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=Team.MAIN_PM,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="x",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _seed_cycle(session: AsyncSession) -> tuple[TaskTable, ProjectTable]:
|
||||
system = await _seed_agent(session, AgentRole.SYSTEM, "system")
|
||||
po = await _seed_agent(session, AgentRole.PRODUCT_OWNER, "product-owner")
|
||||
await _seed_ceo(session)
|
||||
await _seed_main_pm(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="Backend Service",
|
||||
@@ -192,9 +223,10 @@ async def test_list_cycles_returns_authored_cycle(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_item_materializes_backlog_task(
|
||||
async def test_approve_item_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, ceo_client: AsyncClient
|
||||
) -> None:
|
||||
"""Defect fix: see test_roadmap_routes.py's identical assertion update."""
|
||||
task, _project = await _seed_cycle(db_session)
|
||||
resp = await ceo_client.post(f"/api/spackle/cycles/{task.id}/items/item-0/approve")
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
@@ -204,7 +236,9 @@ async def test_approve_item_materializes_backlog_task(
|
||||
|
||||
materialized = await db_session.get(TaskTable, UUID(body["materialized_task_id"]))
|
||||
assert materialized is not None
|
||||
assert materialized.status == TaskStatus.BACKLOG
|
||||
assert materialized.status == TaskStatus.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -2048,9 +2048,17 @@ async def test_activate_batch_root_subtasks_retypes_code_to_planning(
|
||||
when flipping team to main_pm, mirroring approve_and_start, or the
|
||||
main_pm+code combo recurs."""
|
||||
svc = task_setup["svc"]
|
||||
# approve_and_start resolves the main-pm agent by slug — seed it.
|
||||
main_pm = AgentTable(
|
||||
id=uuid4(),
|
||||
# approve_and_start resolves the main-pm agent by slug — seed it. Keyed
|
||||
# on the fixed foundation UUID + an existence check (mirrors
|
||||
# test_ceo_reject_routes_coordination_task_to_main_pm above): the
|
||||
# cross-test-shared DB already has other tests seeding this exact
|
||||
# "main-pm" slug, and a second unconditional insert with a fresh random
|
||||
# id would collide on the slug's unique index.
|
||||
main_pm_id = UUID(AGENT_UUIDS["main-pm"])
|
||||
if await db_session.get(AgentTable, main_pm_id) is None:
|
||||
db_session.add(
|
||||
AgentTable(
|
||||
id=main_pm_id,
|
||||
name="Main PM",
|
||||
slug="main-pm",
|
||||
role=AgentRole.MAIN_PM,
|
||||
@@ -2062,7 +2070,7 @@ async def test_activate_batch_root_subtasks_retypes_code_to_planning(
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(main_pm)
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
batch = uuid4()
|
||||
|
||||
@@ -200,6 +200,10 @@ async def test_propose_postmortem_completes_the_task_and_stamps_marker() -> None
|
||||
assert payload is not None
|
||||
assert payload["failed_stage"] == "awaiting_qa"
|
||||
assert payload["process_change"]["kind"] == "prompt_fix"
|
||||
# A non-playbook process change stays "proposed" — the CEO's per-item
|
||||
# approve/dismiss decision (CoronerService) is still open.
|
||||
assert payload["process_change"]["status"] == "proposed"
|
||||
assert payload["process_change"]["materialized_task_id"] is None
|
||||
assert payload["playbook_id"] is None
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
|
||||
@@ -244,6 +248,9 @@ async def test_propose_postmortem_drafts_playbook_when_kind_is_playbook() -> Non
|
||||
playbook_svc.draft.assert_awaited_once()
|
||||
payload = engine.complete_with_postmortem.await_args.args[1]
|
||||
assert payload["playbook_id"] == str(drafted.id)
|
||||
# A "playbook" kind already routed into the curation queue above —
|
||||
# CoronerService refuses to act on it (see its own test module).
|
||||
assert payload["process_change"]["status"] == "not_applicable"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -424,6 +424,11 @@ async def test_propose_market_brief_persists_and_completes_the_exploration_task(
|
||||
assert payload["headline"] == "A rival tool shipped agentic PR review this week"
|
||||
assert len(payload["findings"]) == len(findings)
|
||||
assert payload["findings"][0]["id"] == "finding-0"
|
||||
# Each finding still carries its own per-item CEO decision (Periscope
|
||||
# Service.approve_finding/reject_finding) even though the exploration
|
||||
# task completes here.
|
||||
assert payload["findings"][0]["status"] == "proposed"
|
||||
assert payload["findings"][0]["materialized_task_id"] is None
|
||||
assert payload["threats"] == ["Feature parity gap"]
|
||||
assert payload["opportunities"] == ["Lean into structured findings"]
|
||||
assert payload["positioning_note"] == "Emphasize the findings ledger in messaging"
|
||||
|
||||
@@ -393,6 +393,11 @@ async def test_propose_quality_report_persists_and_completes_the_exploration_tas
|
||||
assert len(payload["items"]) == len(items)
|
||||
assert payload["items"][0]["id"] == "item-0"
|
||||
assert payload["items"][0]["area"] == "waivers"
|
||||
# Each item still carries its own per-item CEO decision (SentinelService.
|
||||
# approve_item/reject_item) even though the exploration task completes
|
||||
# here.
|
||||
assert payload["items"][0]["status"] == "proposed"
|
||||
assert payload["items"][0]["materialized_task_id"] is None
|
||||
assert (
|
||||
payload["overall_assessment"]
|
||||
== "Drift is concentrated in one hotspot, not systemic"
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
"""CoronerService coverage: approve materializes the postmortem's ONE
|
||||
process change as a Main-PM-owned root task (idempotent), reject records a
|
||||
reason (idempotent). Unlike Periscope/Sentinel there is no item id — a
|
||||
postmortem is one process change, not a list — and the target project
|
||||
resolves against the INCIDENT task's own project (falling back to RoboCo's),
|
||||
not a per-item project_slug.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
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.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
|
||||
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.coroner_service import CoronerService, get_coroner_service
|
||||
from roboco.services.task import CORONER_ITEM_SOURCE, CORONER_SOURCE
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
AUDITOR_UUID = _foundation.AGENTS["auditor"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ROBOCO_SLUG = "roboco-standin"
|
||||
INCIDENT_SLUG = "customer-app"
|
||||
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."""
|
||||
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 == CORONER_SOURCE,
|
||||
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
|
||||
)
|
||||
.values(status=TS.CANCELLED)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _process_change(
|
||||
*, kind: str = "conventions_rule", status: str | None = "proposed"
|
||||
) -> dict:
|
||||
change: dict[str, Any] = {
|
||||
"kind": kind,
|
||||
"description": "Add a venv-freshness check to make quality",
|
||||
}
|
||||
if status is not None:
|
||||
change["status"] = status
|
||||
change["reject_reason"] = None
|
||||
change["materialized_task_id"] = None
|
||||
return change
|
||||
|
||||
|
||||
async def _seed_agents(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
async def _seed_roboco_project(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> ProjectTable:
|
||||
await _seed_agents(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo",
|
||||
slug=ROBOCO_SLUG,
|
||||
git_url="https://example.com/roboco.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
|
||||
return project
|
||||
|
||||
|
||||
async def _seed_incident(session: AsyncSession, *, project: ProjectTable) -> TaskTable:
|
||||
"""A real incident task on its OWN project/team — distinct from any
|
||||
RoboCo fallback project, so a test asserting the incident's project/team
|
||||
wins can't accidentally pass via the fallback instead."""
|
||||
await _seed_agents(session)
|
||||
incident = TaskTable(
|
||||
id=uuid4(),
|
||||
title="Fix worktree venv rot",
|
||||
description="x",
|
||||
acceptance_criteria=["x"],
|
||||
status=TS.NEEDS_REVISION,
|
||||
priority=2,
|
||||
task_type=TT.CODE,
|
||||
nature=TN.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
created_by=SYSTEM_UUID,
|
||||
team=Team.FRONTEND,
|
||||
source="manual",
|
||||
confirmed_by_human=True,
|
||||
project_id=project.id,
|
||||
revision_count=3,
|
||||
)
|
||||
session.add(incident)
|
||||
await session.flush()
|
||||
return incident
|
||||
|
||||
|
||||
async def _seed_incident_project(session: AsyncSession) -> ProjectTable:
|
||||
await _seed_agents(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="Customer App",
|
||||
slug=INCIDENT_SLUG,
|
||||
git_url="https://example.com/customer-app.git",
|
||||
assigned_cell=Team.FRONTEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def _seed_postmortem(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
incident: TaskTable | None,
|
||||
process_change: dict | None = None,
|
||||
postmortem_project_id: object | None = None,
|
||||
) -> TaskTable:
|
||||
await _seed_agents(session)
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="Coroner postmortem",
|
||||
description="Autopsy the chronic task.",
|
||||
acceptance_criteria=["propose_postmortem() called once"],
|
||||
status=TS.COMPLETED,
|
||||
priority=2,
|
||||
task_type=TT.ADMINISTRATIVE,
|
||||
nature=TN.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
created_by=SYSTEM_UUID,
|
||||
assigned_to=AUDITOR_UUID,
|
||||
team=Team.BOARD,
|
||||
source=CORONER_SOURCE,
|
||||
confirmed_by_human=False,
|
||||
project_id=postmortem_project_id,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
if incident is not None:
|
||||
markers.set_coroner_incident(
|
||||
task,
|
||||
{
|
||||
"incident_task_id": str(incident.id),
|
||||
"kind": "bounced",
|
||||
"revision_count": incident.revision_count or 0,
|
||||
"title": incident.title,
|
||||
},
|
||||
)
|
||||
markers.set_coroner_postmortem(
|
||||
task,
|
||||
{
|
||||
"incident_summary": "the task bounced 3 times over a stale venv",
|
||||
"root_cause": "the gate never verified the venv's dev extras",
|
||||
"failed_stage": "awaiting_qa",
|
||||
"process_change": process_change or _process_change(),
|
||||
"playbook_id": None,
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def _svc(session: AsyncSession) -> CoronerService:
|
||||
return get_coroner_service(session)
|
||||
|
||||
|
||||
def _id(task: TaskTable) -> UUID:
|
||||
return cast("UUID", task.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_main_pm_owned_task_on_incident_project(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""The target project/team is the INCIDENT's own — not the postmortem
|
||||
task's own project_id (left None here) and not a RoboCo fallback."""
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
|
||||
result = await _svc(db_session).approve_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
assert result.materialized_task_id is not None
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == CORONER_ITEM_SOURCE
|
||||
assert materialized.project_id == incident_project.id
|
||||
# team is forced to Team.MAIN_PM (not the incident's own cell) — see
|
||||
# test_roadmap_service.py's identical assertion for why: every "is this
|
||||
# a coordination root" consumer keys on team, not assigned_to.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task — see test_roadmap_service.py's
|
||||
# identical assertion for the coercion rationale.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
# The incident's own cell (Team.FRONTEND) survives as a Notes delegation
|
||||
# hint instead of the materialized task's team column.
|
||||
assert "frontend cell" in (materialized.description or "")
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_coroner_postmortem(task)
|
||||
assert payload is not None
|
||||
assert payload["process_change"]["status"] == "approved"
|
||||
assert payload["process_change"]["materialized_task_id"] == str(
|
||||
result.materialized_task_id
|
||||
)
|
||||
assert task.status == TS.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_falls_back_to_roboco_project_when_incident_gone(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
roboco_project = await _seed_roboco_project(db_session, monkeypatch)
|
||||
# coroner_incident references an incident id that no longer resolves.
|
||||
task = await _seed_postmortem(db_session, incident=None)
|
||||
markers.set_coroner_incident(
|
||||
task,
|
||||
{
|
||||
"incident_task_id": str(uuid4()),
|
||||
"kind": "cancelled",
|
||||
"revision_count": 0,
|
||||
"title": "gone",
|
||||
},
|
||||
)
|
||||
await db_session.flush()
|
||||
|
||||
result = await _svc(db_session).approve_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.project_id == roboco_project.id
|
||||
# team is forced to Team.MAIN_PM regardless of the fallback team
|
||||
# (Team.BACKEND) _resolve_target reports when the incident is gone.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
assert "backend cell" in (materialized.description or "")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_is_idempotent(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
svc = _svc(db_session)
|
||||
first = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
|
||||
second = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert second.status == "already_approved"
|
||||
assert second.materialized_task_id == first.materialized_task_id
|
||||
|
||||
result = await db_session.execute(
|
||||
select(TaskTable).where(TaskTable.source == CORONER_ITEM_SOURCE)
|
||||
)
|
||||
assert len(result.scalars().all()) == ONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_records_reason(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
result = await _svc(db_session).reject_process_change(
|
||||
_id(task), "one-off incident, not worth a standing rule"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "rejected"
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_coroner_postmortem(task)
|
||||
assert payload is not None
|
||||
assert payload["process_change"]["status"] == "rejected"
|
||||
assert (
|
||||
payload["process_change"]["reject_reason"]
|
||||
== "one-off incident, not worth a standing rule"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_is_idempotent(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_process_change(_id(task), "reason one")
|
||||
second = await svc.reject_process_change(_id(task), "reason two")
|
||||
assert second is not None
|
||||
assert second.status == "already_rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_reject_an_approved_change(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
svc = _svc(db_session)
|
||||
await svc.approve_process_change(_id(task), created_by=CEO_UUID)
|
||||
result = await svc.reject_process_change(_id(task), "changed my mind")
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_approve_a_rejected_change(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_process_change(_id(task), "not now")
|
||||
result = await svc.approve_process_change(_id(task), created_by=CEO_UUID)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playbook_kind_refuses_approve(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(
|
||||
db_session,
|
||||
incident=incident,
|
||||
process_change=_process_change(kind="playbook", status="not_applicable"),
|
||||
)
|
||||
result = await _svc(db_session).approve_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
assert "already drafted as a playbook" in result.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playbook_kind_refuses_reject(db_session: AsyncSession) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(
|
||||
db_session,
|
||||
incident=incident,
|
||||
process_change=_process_change(kind="playbook", status="not_applicable"),
|
||||
)
|
||||
result = await _svc(db_session).reject_process_change(_id(task), "no thanks")
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
assert "already drafted as a playbook" in result.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_change_with_no_status_key_defaults_to_proposed(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
legacy_change = _process_change(status=None)
|
||||
assert "status" not in legacy_change
|
||||
task = await _seed_postmortem(
|
||||
db_session, incident=incident, process_change=legacy_change
|
||||
)
|
||||
result = await _svc(db_session).approve_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_unresolvable_project_is_invalid_state(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Incident gone AND no RoboCo project seeded — fails cleanly instead of
|
||||
guessing a project."""
|
||||
task = await _seed_postmortem(db_session, incident=None)
|
||||
markers.set_coroner_incident(
|
||||
task,
|
||||
{
|
||||
"incident_task_id": str(uuid4()),
|
||||
"kind": "cancelled",
|
||||
"revision_count": 0,
|
||||
"title": "gone",
|
||||
},
|
||||
)
|
||||
await db_session.flush()
|
||||
result = await _svc(db_session).approve_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
assert "cannot anchor a materialized task" in result.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
|
||||
result = await _svc(db_session).approve_process_change(uuid4(), created_by=CEO_UUID)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
|
||||
session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="coroner",
|
||||
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:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
await _seed_cycle_ledger_row(db_session, task)
|
||||
await _svc(db_session).approve_process_change(_id(task), created_by=CEO_UUID)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.program_key == "coroner"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.items_approved == ONE
|
||||
decision = row.decisions[0]
|
||||
assert decision["verdict"] == "approved"
|
||||
assert decision["item_ref"] == "Add a venv-freshness check to make quality"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_survives_learn_recording_failure(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
incident_project = await _seed_incident_project(db_session)
|
||||
incident = await _seed_incident(db_session, project=incident_project)
|
||||
task = await _seed_postmortem(db_session, incident=incident)
|
||||
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_process_change(
|
||||
_id(task), created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
@@ -25,6 +25,7 @@ from roboco.db.tables import (
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
@@ -109,6 +111,7 @@ async def _seed_agents(session: AsyncSession) -> None:
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -187,7 +190,12 @@ def _id(task: TaskTable) -> UUID:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
|
||||
update — approval materializes PENDING + assigned_to=main-pm, never an
|
||||
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
|
||||
await _seed_project(db_session, "frontend-app")
|
||||
task = await _seed_cycle(db_session, project_slug="frontend-app")
|
||||
result = await _svc(db_session).approve_item(
|
||||
@@ -199,9 +207,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.BACKLOG
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == DOGFOOD_ITEM_SOURCE
|
||||
assert materialized.team == Team.FRONTEND
|
||||
# team is forced to Team.MAIN_PM (not the item's own cell) — see
|
||||
# test_roadmap_service.py's identical assertion for why: every "is this
|
||||
# a coordination root" consumer keys on team, not assigned_to.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task — see test_roadmap_service.py's
|
||||
# identical assertion for the coercion rationale.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
# The item's own cell survives as a Notes delegation hint instead.
|
||||
assert "frontend cell" in (materialized.description or "")
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_friction_fixes(task)
|
||||
|
||||
@@ -25,6 +25,7 @@ from roboco.db.tables import (
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
@@ -57,6 +58,7 @@ if TYPE_CHECKING:
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
@@ -111,6 +113,7 @@ async def _seed_agents(session: AsyncSession) -> None:
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -189,7 +192,12 @@ def _id(task: TaskTable) -> UUID:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
|
||||
update — approval materializes PENDING + assigned_to=main-pm, never an
|
||||
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
|
||||
await _seed_project(db_session, "backend-svc")
|
||||
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
||||
result = await _svc(db_session).approve_item(
|
||||
@@ -201,10 +209,22 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.BACKLOG
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == MIRROR_ITEM_SOURCE
|
||||
assert materialized.task_type == TT.DOCUMENTATION
|
||||
assert materialized.team == Team.BACKEND
|
||||
# team is forced to Team.MAIN_PM (not the item's own cell) — see
|
||||
# test_roadmap_service.py's identical assertion for why: every "is this
|
||||
# a coordination root" consumer keys on team, not assigned_to.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# The item's own cell survives as a Notes delegation hint instead.
|
||||
assert "backend cell" in (materialized.description or "")
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_messaging_fixes(task)
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""PeriscopeService coverage: per-finding approve materializes a Main-PM-
|
||||
owned root task (idempotent), reject records a reason (idempotent). Unlike
|
||||
RoadmapService the exploration task is ALREADY COMPLETED (complete-at-
|
||||
propose) — there is no cycle-completion transition to test, only the
|
||||
finding's own status.
|
||||
|
||||
Mirrors test_roadmap_service.py's per-item shape, adapted for: no
|
||||
project_slug on the item (resolves against the RoboCo project instead), and
|
||||
a finding authored before this feature shipped carrying no status key at all
|
||||
(setdefault, not a hard requirement).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
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.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
|
||||
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.periscope_service import PeriscopeService, get_periscope_service
|
||||
from roboco.services.task import PERISCOPE_ITEM_SOURCE, PERISCOPE_SOURCE
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
HOM_UUID = _foundation.AGENTS["head-marketing"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ROBOCO_SLUG = "roboco-standin"
|
||||
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."""
|
||||
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 == PERISCOPE_SOURCE,
|
||||
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
|
||||
)
|
||||
.values(status=TS.CANCELLED)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _finding(idx: int, *, status: str | None = "proposed") -> dict:
|
||||
finding: dict[str, Any] = {
|
||||
"id": f"finding-{idx}",
|
||||
"claim": f"Competitor {idx} shipped an autonomous review agent",
|
||||
"source_url": f"https://example.com/competitor-{idx}",
|
||||
"relevance": f"Overlaps our pr_reviewer role, finding {idx}",
|
||||
}
|
||||
if status is not None:
|
||||
finding["status"] = status
|
||||
finding["reject_reason"] = None
|
||||
finding["materialized_task_id"] = None
|
||||
return finding
|
||||
|
||||
|
||||
async def _seed_agents(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(HOM_UUID, "head-marketing", AgentRole.HEAD_MARKETING, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
async def _seed_roboco_project(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> ProjectTable:
|
||||
"""The org's own project — the RoboCo project resolution anchor every
|
||||
Periscope/Sentinel/Coroner materialization falls back to. Mirrors
|
||||
test_periscope_engine.py's ``_seed``/``_arm`` shape."""
|
||||
await _seed_agents(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo",
|
||||
slug=ROBOCO_SLUG,
|
||||
git_url="https://example.com/roboco.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
|
||||
return project
|
||||
|
||||
|
||||
async def _seed_brief(
|
||||
session: AsyncSession, *, findings: list[dict] | None = None
|
||||
) -> TaskTable:
|
||||
await _seed_agents(session)
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="Periscope market-research cycle",
|
||||
description="Research the market and file ONE brief.",
|
||||
acceptance_criteria=["propose_market_brief() called once"],
|
||||
status=TS.COMPLETED,
|
||||
priority=2,
|
||||
task_type=TT.ADMINISTRATIVE,
|
||||
nature=TN.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
created_by=SYSTEM_UUID,
|
||||
assigned_to=HOM_UUID,
|
||||
team=Team.BOARD,
|
||||
source=PERISCOPE_SOURCE,
|
||||
confirmed_by_human=False,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
findings = findings or [_finding(0), _finding(1)]
|
||||
markers.set_market_brief(
|
||||
task,
|
||||
{
|
||||
"headline": "A rival tool shipped agentic PR review",
|
||||
"findings": findings,
|
||||
"threats": [],
|
||||
"opportunities": [],
|
||||
"positioning_note": "",
|
||||
"injection_hits": [],
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def _svc(session: AsyncSession) -> PeriscopeService:
|
||||
return get_periscope_service(session)
|
||||
|
||||
|
||||
def _id(task: TaskTable) -> UUID:
|
||||
return cast("UUID", task.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
result = await _svc(db_session).approve_finding(
|
||||
_id(task), "finding-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
assert result.materialized_task_id is not None
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == PERISCOPE_ITEM_SOURCE
|
||||
# team is forced to Team.MAIN_PM — see test_roadmap_service.py's
|
||||
# identical assertion for why: every "is this a coordination root"
|
||||
# consumer keys on team, not assigned_to. A market signal has no natural
|
||||
# owning cell (the prior Team.BACKEND was an arbitrary placeholder, not
|
||||
# a real delegation hint), so there is no cell to preserve in Notes here.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task (pm_cannot_own_code) — the intake
|
||||
# coercion in create_task_from_draft retypes it to planning, the same
|
||||
# shape a Main-PM coordination root always carries.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_market_brief(task)
|
||||
assert payload is not None
|
||||
finding0 = next(f for f in payload["findings"] if f["id"] == "finding-0")
|
||||
assert finding0["status"] == "approved"
|
||||
assert finding0["materialized_task_id"] == result.materialized_task_id
|
||||
# The exploration task itself stays COMPLETED — approving a finding is
|
||||
# orthogonal to the (already terminal) cycle.
|
||||
assert task.status == TS.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_is_idempotent(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
svc = _svc(db_session)
|
||||
first = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
|
||||
second = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert second.status == "already_approved"
|
||||
assert second.materialized_task_id == first.materialized_task_id
|
||||
|
||||
result = await db_session.execute(
|
||||
select(TaskTable).where(
|
||||
TaskTable.source == PERISCOPE_ITEM_SOURCE,
|
||||
TaskTable.title.like("Market signal:%"),
|
||||
)
|
||||
)
|
||||
assert len(result.scalars().all()) == ONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_records_reason(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
result = await _svc(db_session).reject_finding(
|
||||
_id(task), "finding-0", "not actionable this quarter"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "rejected"
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_market_brief(task)
|
||||
assert payload is not None
|
||||
finding0 = next(f for f in payload["findings"] if f["id"] == "finding-0")
|
||||
assert finding0["status"] == "rejected"
|
||||
assert finding0["reject_reason"] == "not actionable this quarter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_is_idempotent(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_finding(_id(task), "finding-0", "reason one")
|
||||
second = await svc.reject_finding(_id(task), "finding-0", "reason two")
|
||||
assert second is not None
|
||||
assert second.status == "already_rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_reject_an_approved_finding(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
|
||||
result = await svc.reject_finding(_id(task), "finding-0", "changed my mind")
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_approve_a_rejected_finding(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_finding(_id(task), "finding-0", "not now")
|
||||
result = await svc.approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finding_with_no_status_key_defaults_to_proposed(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A finding authored before this feature shipped carries no status key
|
||||
at all — setdefault treats it as proposed, not a crash."""
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
legacy_finding = _finding(0, status=None)
|
||||
assert "status" not in legacy_finding
|
||||
task = await _seed_brief(db_session, findings=[legacy_finding])
|
||||
result = await _svc(db_session).approve_finding(
|
||||
_id(task), "finding-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_unresolvable_project_is_invalid_state(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""No RoboCo project seeded (and no monkeypatched slug pointing at one)
|
||||
— the materialize fails cleanly instead of guessing a project."""
|
||||
task = await _seed_brief(db_session)
|
||||
result = await _svc(db_session).approve_finding(
|
||||
_id(task), "finding-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
assert "not resolvable" in result.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_task_returns_none(db_session: AsyncSession) -> None:
|
||||
result = await _svc(db_session).approve_finding(
|
||||
uuid4(), "finding-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_finding_id_returns_none(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
result = await _svc(db_session).approve_finding(
|
||||
_id(task), "finding-999", created_by=CEO_UUID
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
|
||||
session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="periscope",
|
||||
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, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
await _seed_cycle_ledger_row(db_session, task)
|
||||
await _svc(db_session).approve_finding(_id(task), "finding-0", created_by=CEO_UUID)
|
||||
|
||||
row = (
|
||||
await db_session.execute(
|
||||
select(BoardProgramCycleTable).where(
|
||||
BoardProgramCycleTable.program_key == "periscope"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.items_approved == ONE
|
||||
# The ref is the finding's CLAIM (wrapped as learn_ref's "title" input) —
|
||||
# a finding has no "title" field of its own.
|
||||
decision = row.decisions[0]
|
||||
assert decision["verdict"] == "approved"
|
||||
assert decision["item_ref"] == "Competitor 0 shipped an autonomous review agent"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_survives_learn_recording_failure(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_brief(db_session)
|
||||
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_finding(
|
||||
_id(task), "finding-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
@@ -25,6 +25,7 @@ from roboco.db.tables import (
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
@@ -106,6 +108,7 @@ async def _seed_agents(session: AsyncSession) -> None:
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -184,7 +187,12 @@ def _id(task: TaskTable) -> UUID:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
|
||||
update — approval materializes PENDING + assigned_to=main-pm, never an
|
||||
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
|
||||
await _seed_project(db_session, "backend-svc")
|
||||
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
||||
result = await _svc(db_session).approve_item(
|
||||
@@ -196,9 +204,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.BACKLOG
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == PEST_CONTROL_ITEM_SOURCE
|
||||
assert materialized.team == Team.BACKEND
|
||||
# team is forced to Team.MAIN_PM (not the item's own cell) — see
|
||||
# test_roadmap_service.py's identical assertion for why: every "is this
|
||||
# a coordination root" consumer keys on team, not assigned_to.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task — see test_roadmap_service.py's
|
||||
# identical assertion for the coercion rationale.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
# The item's own cell survives as a Notes delegation hint instead.
|
||||
assert "backend cell" in (materialized.description or "")
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_pest_hunt(task)
|
||||
|
||||
@@ -23,6 +23,7 @@ from roboco.db.tables import (
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
@@ -49,6 +50,7 @@ if TYPE_CHECKING:
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
@@ -98,6 +100,7 @@ async def _seed_agents(session: AsyncSession) -> None:
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -175,7 +178,20 @@ def _id(task: TaskTable) -> UUID:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Defect fix (#703/#704): approval used to materialize an unowned
|
||||
BACKLOG task — nothing dispatches BACKLOG, and once nudged to PENDING a
|
||||
cell PM could claim + complete it as a bare root, merging straight to
|
||||
the project's head rung and bypassing the Main-PM root / root->master PR
|
||||
/ CEO approval gate. It now materializes PENDING + assigned_to=main-pm
|
||||
instead — team is forced to Team.MAIN_PM too (matching
|
||||
TaskService.approve_and_start), since every "is this a coordination
|
||||
root" consumer (pr_fail's next-hint, the PR-gate re-delegate steer,
|
||||
delegate's wave-chain wiring, the PR labeler) keys on team, not
|
||||
assigned_to. Leaving team on the item's own cell was itself the defect:
|
||||
the root looked like a bare cell/dev task to all four."""
|
||||
await _seed_project(db_session, "backend-svc")
|
||||
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
||||
result = await _svc(db_session).approve_item(
|
||||
@@ -187,9 +203,29 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.BACKLOG
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == ROADMAP_ITEM_SOURCE
|
||||
assert materialized.team == Team.BACKEND
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task (pm_cannot_own_code) — the intake
|
||||
# coercion in create_task_from_draft retypes it to planning, the same
|
||||
# shape a Main-PM coordination root always carries.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
# The item's own cell ("backend") didn't just vanish — it survives as a
|
||||
# Notes delegation hint in the composed description, which the Main PM's
|
||||
# spawn briefing (_format_task_briefing_block) renders verbatim.
|
||||
assert "backend cell" in (materialized.description or "")
|
||||
|
||||
# The predicate the four consumers key on: with team=Team.MAIN_PM and a
|
||||
# branch (simulating a claimed root with its assembled PR), pr_fail's
|
||||
# next-hint steers the Main PM to re-delegate rather than the nonsensical
|
||||
# "dev will revise" hint a Main PM (no code-revise verb) can't act on.
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_roadmap_cycle(task)
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""SentinelService coverage: per-item approve materializes a Main-PM-owned
|
||||
root task (idempotent), reject records a reason (idempotent). Mirrors
|
||||
test_periscope_service.py exactly — the exploration task is ALREADY
|
||||
COMPLETED (complete-at-propose), so only the item's own status is under
|
||||
test, plus the docs-area task_type override (mirrors MirrorService).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
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.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import AgentRole, AgentStatus, Complexity, Team
|
||||
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.sentinel_service import SentinelService, get_sentinel_service
|
||||
from roboco.services.task import SENTINEL_ITEM_SOURCE, SENTINEL_SOURCE
|
||||
from sqlalchemy import delete, select, update
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
AUDITOR_UUID = _foundation.AGENTS["auditor"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ROBOCO_SLUG = "roboco-standin"
|
||||
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."""
|
||||
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 == SENTINEL_SOURCE,
|
||||
TaskTable.status.notin_([TS.COMPLETED, TS.CANCELLED]),
|
||||
)
|
||||
.values(status=TS.CANCELLED)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
def _item(idx: int, *, area: str = "waivers", status: str | None = "proposed") -> dict:
|
||||
item: dict[str, Any] = {
|
||||
"id": f"item-{idx}",
|
||||
"area": area,
|
||||
"observation": f"Minor findings keep getting waived, item {idx}",
|
||||
"evidence": f"{idx + 3} waived-minor findings this week",
|
||||
"suggested_action": f"Convert item {idx} to a Pest Control bug task",
|
||||
}
|
||||
if status is not None:
|
||||
item["status"] = status
|
||||
item["reject_reason"] = None
|
||||
item["materialized_task_id"] = None
|
||||
return item
|
||||
|
||||
|
||||
async def _seed_agents(session: AsyncSession) -> None:
|
||||
for uuid, slug, role, team in (
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(AUDITOR_UUID, "auditor", AgentRole.AUDITOR, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
async def _seed_roboco_project(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> ProjectTable:
|
||||
await _seed_agents(session)
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="RoboCo",
|
||||
slug=ROBOCO_SLUG,
|
||||
git_url="https://example.com/roboco.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=SYSTEM_UUID,
|
||||
)
|
||||
session.add(project)
|
||||
await session.flush()
|
||||
monkeypatch.setattr(cfg, "self_heal_project_slug", ROBOCO_SLUG)
|
||||
return project
|
||||
|
||||
|
||||
async def _seed_report(
|
||||
session: AsyncSession, *, items: list[dict] | None = None
|
||||
) -> TaskTable:
|
||||
await _seed_agents(session)
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title="Sentinel drift-watch cycle",
|
||||
description="Assess org-wide quality drift and file ONE report.",
|
||||
acceptance_criteria=["propose_quality_report() called once"],
|
||||
status=TS.COMPLETED,
|
||||
priority=2,
|
||||
task_type=TT.ADMINISTRATIVE,
|
||||
nature=TN.NON_TECHNICAL,
|
||||
estimated_complexity=Complexity.LOW,
|
||||
created_by=SYSTEM_UUID,
|
||||
assigned_to=AUDITOR_UUID,
|
||||
team=Team.BOARD,
|
||||
source=SENTINEL_SOURCE,
|
||||
confirmed_by_human=False,
|
||||
)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
items = items or [_item(0), _item(1)]
|
||||
markers.set_quality_report(
|
||||
task,
|
||||
{
|
||||
"headline": "Waived findings climbed sharply this week",
|
||||
"items": items,
|
||||
"overall_assessment": "Drift is concentrated, not systemic",
|
||||
},
|
||||
)
|
||||
await session.flush()
|
||||
return task
|
||||
|
||||
|
||||
def _svc(session: AsyncSession) -> SentinelService:
|
||||
return get_sentinel_service(session)
|
||||
|
||||
|
||||
def _id(task: TaskTable) -> UUID:
|
||||
return cast("UUID", task.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
result = await _svc(db_session).approve_item(
|
||||
_id(task), "item-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
assert result.materialized_task_id is not None
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == SENTINEL_ITEM_SOURCE
|
||||
# team is forced to Team.MAIN_PM — see test_roadmap_service.py's
|
||||
# identical assertion for why: every "is this a coordination root"
|
||||
# consumer keys on team, not assigned_to. A process/quality drift item
|
||||
# has no natural owning cell (the prior Team.BACKEND was an arbitrary
|
||||
# placeholder, not a real delegation hint), so there is no cell to
|
||||
# preserve in Notes here.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task (pm_cannot_own_code) — the intake
|
||||
# coercion in create_task_from_draft retypes it to planning, the same
|
||||
# shape a Main-PM coordination root always carries.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_quality_report(task)
|
||||
assert payload is not None
|
||||
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
|
||||
assert item0["status"] == "approved"
|
||||
assert item0["materialized_task_id"] == result.materialized_task_id
|
||||
assert task.status == TS.COMPLETED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docs_area_materializes_documentation_task(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session, items=[_item(0, area="docs")])
|
||||
result = await _svc(db_session).approve_item(
|
||||
_id(task), "item-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.task_type == TT.DOCUMENTATION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_is_idempotent(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
svc = _svc(db_session)
|
||||
first = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
||||
second = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
||||
assert first is not None
|
||||
assert second is not None
|
||||
assert second.status == "already_approved"
|
||||
assert second.materialized_task_id == first.materialized_task_id
|
||||
|
||||
result = await db_session.execute(
|
||||
select(TaskTable).where(
|
||||
TaskTable.source == SENTINEL_ITEM_SOURCE,
|
||||
TaskTable.title.like("Sentinel [waivers]:%"),
|
||||
)
|
||||
)
|
||||
assert len(result.scalars().all()) == ONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_records_reason(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
result = await _svc(db_session).reject_item(
|
||||
_id(task), "item-0", "already tracked elsewhere"
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "rejected"
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_quality_report(task)
|
||||
assert payload is not None
|
||||
item0 = next(i for i in payload["items"] if i["id"] == "item-0")
|
||||
assert item0["status"] == "rejected"
|
||||
assert item0["reject_reason"] == "already tracked elsewhere"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_is_idempotent(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_item(_id(task), "item-0", "reason one")
|
||||
second = await svc.reject_item(_id(task), "item-0", "reason two")
|
||||
assert second is not None
|
||||
assert second.status == "already_rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_reject_an_approved_item(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
||||
result = await svc.reject_item(_id(task), "item-0", "changed my mind")
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_approve_a_rejected_item(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
svc = _svc(db_session)
|
||||
await svc.reject_item(_id(task), "item-0", "not now")
|
||||
result = await svc.approve_item(_id(task), "item-0", created_by=CEO_UUID)
|
||||
assert result is not None
|
||||
assert result.status == "invalid_state"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_item_with_no_status_key_defaults_to_proposed(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
legacy_item = _item(0, status=None)
|
||||
assert "status" not in legacy_item
|
||||
task = await _seed_report(db_session, items=[legacy_item])
|
||||
result = await _svc(db_session).approve_item(
|
||||
_id(task), "item-0", created_by=CEO_UUID
|
||||
)
|
||||
assert result is not None
|
||||
assert result.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_unresolvable_project_is_invalid_state(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
task = await _seed_report(db_session)
|
||||
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 "not resolvable" 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)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_item_id_returns_none(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
result = await _svc(db_session).approve_item(
|
||||
_id(task), "item-999", created_by=CEO_UUID
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def _seed_cycle_ledger_row(session: AsyncSession, task: TaskTable) -> None:
|
||||
session.add(
|
||||
BoardProgramCycleTable(
|
||||
program_key="sentinel",
|
||||
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, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
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 == "sentinel"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.items_approved == ONE
|
||||
decision = row.decisions[0]
|
||||
assert decision["verdict"] == "approved"
|
||||
assert decision["item_ref"] == "Convert item 0 to a Pest Control bug task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_survives_learn_recording_failure(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
await _seed_roboco_project(db_session, monkeypatch)
|
||||
task = await _seed_report(db_session)
|
||||
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"
|
||||
@@ -25,6 +25,7 @@ from roboco.db.tables import (
|
||||
)
|
||||
from roboco.foundation import identity as _foundation
|
||||
from roboco.foundation.policy.content import markers
|
||||
from roboco.foundation.policy.lifecycle import _next_hint_pr_fail
|
||||
from roboco.models.base import (
|
||||
AgentRole,
|
||||
AgentStatus,
|
||||
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
|
||||
SYSTEM_UUID = _foundation.AGENTS["system"].uuid
|
||||
PO_UUID = _foundation.AGENTS["product-owner"].uuid
|
||||
CEO_UUID = _foundation.AGENTS["ceo"].uuid
|
||||
MAIN_PM_UUID = _foundation.AGENTS["main-pm"].uuid
|
||||
ONE = 1
|
||||
TWO = 2
|
||||
|
||||
@@ -109,6 +111,7 @@ async def _seed_agents(session: AsyncSession) -> None:
|
||||
(SYSTEM_UUID, "system", AgentRole.SYSTEM, None),
|
||||
(PO_UUID, "product-owner", AgentRole.PRODUCT_OWNER, Team.BOARD),
|
||||
(CEO_UUID, "ceo", AgentRole.CEO, None),
|
||||
(MAIN_PM_UUID, "main-pm", AgentRole.MAIN_PM, Team.MAIN_PM),
|
||||
):
|
||||
if await session.get(AgentTable, uuid) is None:
|
||||
session.add(
|
||||
@@ -187,7 +190,12 @@ def _id(task: TaskTable) -> UUID:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> None:
|
||||
async def test_approve_materializes_main_pm_owned_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""Defect fix: mirrors test_roadmap_service.py's identical assertion
|
||||
update — approval materializes PENDING + assigned_to=main-pm, never an
|
||||
unowned BACKLOG task (see RoadmapService._materialize's docstring)."""
|
||||
await _seed_project(db_session, "backend-svc")
|
||||
task = await _seed_cycle(db_session, project_slug="backend-svc")
|
||||
result = await _svc(db_session).approve_item(
|
||||
@@ -199,9 +207,24 @@ async def test_approve_materializes_backlog_task(db_session: AsyncSession) -> No
|
||||
|
||||
materialized = await db_session.get(TaskTable, result.materialized_task_id)
|
||||
assert materialized is not None
|
||||
assert materialized.status == TS.BACKLOG
|
||||
assert materialized.status == TS.PENDING
|
||||
assert materialized.assigned_to == MAIN_PM_UUID
|
||||
assert materialized.parent_task_id is None
|
||||
assert materialized.source == SPACKLE_ITEM_SOURCE
|
||||
assert materialized.team == Team.BACKEND
|
||||
# team is forced to Team.MAIN_PM (not the item's own cell) — see
|
||||
# test_roadmap_service.py's identical assertion for why: every "is this
|
||||
# a coordination root" consumer keys on team, not assigned_to.
|
||||
assert materialized.team == Team.MAIN_PM
|
||||
# main_pm can never own a code task — see test_roadmap_service.py's
|
||||
# identical assertion for the coercion rationale.
|
||||
assert materialized.task_type == TT.PLANNING
|
||||
# The item's own cell survives as a Notes delegation hint instead.
|
||||
assert "backend cell" in (materialized.description or "")
|
||||
|
||||
materialized.branch_name = "feature/main_pm/deadbeef"
|
||||
hint = _next_hint_pr_fail(materialized)
|
||||
assert "re-delegate" in hint
|
||||
assert "do NOT re-submit" in hint
|
||||
|
||||
await db_session.refresh(task)
|
||||
payload = markers.get_gap_fill(task)
|
||||
|
||||
Reference in New Issue
Block a user