diff --git a/panel/src/app/(tg)/tg/__tests__/page.test.tsx b/panel/src/app/(tg)/tg/__tests__/page.test.tsx index 2c0fa78d..02a7f539 100644 --- a/panel/src/app/(tg)/tg/__tests__/page.test.tsx +++ b/panel/src/app/(tg)/tg/__tests__/page.test.tsx @@ -29,6 +29,9 @@ vi.mock("@/lib/api/client", () => ({ vi.mock("@/components/tg/tg-tab-bar", () => ({ TgTabBar: () =>
, })); +vi.mock("@/components/tg/tg-today-tab", () => ({ + TgTodayTab: () =>
, +})); vi.mock("@/components/tg/tg-approvals-tab", () => ({ TgApprovalsTab: () =>
, })); @@ -96,8 +99,8 @@ describe("TelegramMiniAppPage — auth bootstrap", () => { expect(post).toHaveBeenCalledWith("/telegram/webapp-auth", { init_data: "real-init-data", }); - // Default tab is Approvals. - expect(screen.getByTestId("tg-approvals-tab")).toBeInTheDocument(); + // Default tab is Today. + expect(screen.getByTestId("tg-today-tab")).toBeInTheDocument(); }); it("renders an error screen with the server's message when auth is refused", async () => { diff --git a/panel/src/app/(tg)/tg/page.tsx b/panel/src/app/(tg)/tg/page.tsx index 150eed9f..3c5e2dc8 100644 --- a/panel/src/app/(tg)/tg/page.tsx +++ b/panel/src/app/(tg)/tg/page.tsx @@ -11,6 +11,7 @@ import { import { startTelegramThemeSync } from "@/lib/telegram/theme"; import { TgWebAppProvider } from "@/lib/telegram/hooks"; import { TgTabBar, type TgTab } from "@/components/tg/tg-tab-bar"; +import { TgTodayTab } from "@/components/tg/tg-today-tab"; import { TgApprovalsTab } from "@/components/tg/tg-approvals-tab"; import { TgInboxTab } from "@/components/tg/tg-inbox-tab"; import { TgBoardTab } from "@/components/tg/tg-board-tab"; @@ -46,7 +47,7 @@ function CenteredMessage({ children }: { children: React.ReactNode }) { */ export default function TelegramMiniAppPage() { const [state, setState] = useState({ kind: "validating" }); - const [tab, setTab] = useState("approvals"); + const [tab, setTab] = useState("today"); useEffect(() => { let cancelled = false; @@ -129,6 +130,7 @@ export default function TelegramMiniAppPage() { return (
+ {tab === "today" && } {tab === "approvals" && } {tab === "inbox" && } {tab === "board" && } diff --git a/panel/src/components/tg/__tests__/tg-today-tab.test.tsx b/panel/src/components/tg/__tests__/tg-today-tab.test.tsx new file mode 100644 index 00000000..9ad1a10d --- /dev/null +++ b/panel/src/components/tg/__tests__/tg-today-tab.test.tsx @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TgTodayTab } from "../tg-today-tab"; + +const { get } = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@/lib/api/client", () => ({ default: { get } })); + +function renderTab(onNavigate = vi.fn()) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + , + ); + return onNavigate; +} + +function brief(overrides: Record = {}) { + return { + needs_you: { + total: 0, + awaiting_ceo_count: 0, + awaiting_ceo: [], + blocked_count: 0, + blocked: [], + held_drafts: { + release_proposals: 0, + x_posts: 0, + video_posts: 0, + roadmap_items: 0, + }, + }, + fleet: { total: 3, by_status: { active: 3 }, working: [] }, + spend: { tokens_today: 1_234_000, cost_today_usd: 12.34 }, + ship: { version: "0.25.0", open_release_proposal: false, ci_fix_tasks: 0 }, + ...overrides, + }; +} + +describe("TgTodayTab", () => { + // Block body on purpose: returning the mock from beforeEach would make + // vitest call it as an after-test teardown hook. + beforeEach(() => { + get.mockReset(); + }); + + it("shows skeletons while loading", () => { + get.mockReturnValue(new Promise(() => {})); + renderTab(); + expect(document.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan( + 0, + ); + }); + + it("renders the all-clear state and the spend/ship numbers", async () => { + get.mockResolvedValue({ data: brief() }); + renderTab(); + + expect(await screen.findByText(/all clear/i)).toBeInTheDocument(); + expect(screen.getByText("$12.34")).toBeInTheDocument(); + expect(screen.getByText(/1\.2M tokens/)).toBeInTheDocument(); + expect(screen.getByText("v0.25.0")).toBeInTheDocument(); + expect(screen.getByText(/no release pending/i)).toBeInTheDocument(); + expect(screen.getByText("3 agents")).toBeInTheDocument(); + }); + + it("renders needs-you items and deep-links taps into the right tab", async () => { + get.mockResolvedValue({ + data: brief({ + needs_you: { + total: 3, + awaiting_ceo_count: 1, + awaiting_ceo: [ + { + id: "t1", + title: "Root PR ready", + status: "awaiting_ceo_approval", + team: "backend", + updated_at: null, + }, + ], + blocked_count: 0, + blocked: [], + held_drafts: { + release_proposals: 1, + x_posts: 1, + video_posts: 0, + roadmap_items: 0, + }, + }, + }), + }); + const onNavigate = vi.fn(); + renderTab(onNavigate); + + await userEvent.click(await screen.findByText(/Release · 1/)); + expect(onNavigate).toHaveBeenCalledWith("approvals"); + + await userEvent.click(screen.getByText("Root PR ready")); + expect(onNavigate).toHaveBeenCalledWith("board"); + }); + + it("shows an error state when the brief fails to load", async () => { + get.mockRejectedValue(new Error("boom")); + renderTab(); + await waitFor(() => + expect(screen.getByText(/couldn.t load/i)).toBeInTheDocument(), + ); + }); +}); diff --git a/panel/src/components/tg/tg-tab-bar.tsx b/panel/src/components/tg/tg-tab-bar.tsx index 0e407181..51f8991b 100644 --- a/panel/src/components/tg/tg-tab-bar.tsx +++ b/panel/src/components/tg/tg-tab-bar.tsx @@ -1,15 +1,16 @@ "use client"; -import { CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react"; +import { Gauge, CheckSquare, Bell, Kanban, MessageSquare } from "lucide-react"; import { cn } from "@/lib/utils"; -export type TgTab = "approvals" | "inbox" | "board" | "chat"; +export type TgTab = "today" | "approvals" | "inbox" | "board" | "chat"; const TABS: ReadonlyArray<{ id: TgTab; label: string; icon: typeof CheckSquare; }> = [ + { id: "today", label: "Today", icon: Gauge }, { id: "approvals", label: "Approvals", icon: CheckSquare }, { id: "inbox", label: "Inbox", icon: Bell }, { id: "board", label: "Board", icon: Kanban }, diff --git a/panel/src/components/tg/tg-today-tab.tsx b/panel/src/components/tg/tg-today-tab.tsx new file mode 100644 index 00000000..a8fab056 --- /dev/null +++ b/panel/src/components/tg/tg-today-tab.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import api from "@/lib/api/client"; +import { haptics } from "@/lib/telegram/webapp"; +import type { TgTab } from "@/components/tg/tg-tab-bar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + AlertTriangle, + CheckCircle2, + ChevronRight, + CircleDollarSign, + Rocket, + Users, +} from "lucide-react"; +import { formatDistanceToNow } from "date-fns"; + +interface TodayTaskItem { + id: string; + title: string; + status: string; + team: string | null; + updated_at: string | null; +} + +interface TodayBrief { + needs_you: { + total: number; + awaiting_ceo_count: number; + awaiting_ceo: TodayTaskItem[]; + blocked_count: number; + blocked: TodayTaskItem[]; + held_drafts: Record; + }; + fleet: { + total: number; + by_status: Record; + working: Array<{ + name: string; + role: string; + team: string | null; + task_title: string | null; + }>; + }; + spend: { tokens_today: number; cost_today_usd: number }; + ship: { + version: string; + open_release_proposal: boolean; + ci_fix_tasks: number; + }; +} + +const REFETCH_MS = 45_000; + +const DRAFT_LABELS: Record = { + release_proposals: "Release", + x_posts: "X posts", + video_posts: "Videos", + roadmap_items: "Roadmap", +}; + +const compactNumber = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + +function SectionCard({ + icon: Icon, + title, + children, +}: { + icon: typeof Users; + title: string; + children: React.ReactNode; +}) { + return ( +
+

+ + {title} +

+ {children} +
+ ); +} + +function TaskRow({ + task, + onOpen, +}: { + task: TodayTaskItem; + onOpen: () => void; +}) { + return ( + + ); +} + +/** + * The cockpit's home screen: one glance answering "does anything need me?" + * — capped needs-you items, held-draft counts, fleet, today's spend, and + * ship state, off the single aggregated `/telegram/today` round trip. + * Row taps deep-link into the tab that acts on the item. + */ +export function TgTodayTab({ + onNavigate, +}: { + onNavigate: (tab: TgTab) => void; +}) { + const { data, isLoading, isError } = useQuery({ + queryKey: ["tg-today"], + queryFn: async () => (await api.get("/telegram/today")).data, + refetchInterval: REFETCH_MS, + }); + + if (isLoading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); + } + + if (isError || !data) { + return ( +
+ +

Couldn't load the brief

+
+ ); + } + + const { needs_you: needs, fleet, spend, ship } = data; + const go = (tab: TgTab) => { + haptics.tap(); + onNavigate(tab); + }; + const heldEntries = Object.entries(needs.held_drafts).filter( + ([, count]) => count > 0, + ); + + return ( +
+ + {needs.total === 0 ? ( +

+ All clear — nothing is waiting on you. +

+ ) : ( +
+ {heldEntries.length > 0 && ( +
+ {heldEntries.map(([key, count]) => ( + + ))} +
+ )} + {needs.awaiting_ceo.map((t) => ( + go("board")} /> + ))} + {needs.blocked_count > 0 && ( +

+ {needs.blocked_count} blocked +

+ )} + {needs.blocked.map((t) => ( + go("board")} /> + ))} +
+ )} +
+ + +
+ {fleet.total} agents + {Object.entries(fleet.by_status).map(([status, count]) => ( + + {count} {status} + + ))} +
+ {fleet.working.length === 0 ? ( +

No one is mid-task.

+ ) : ( +
    + {fleet.working.map((agent) => ( +
  • + {agent.name} + {agent.task_title && ( + + {" "} + — {agent.task_title} + + )} +
  • + ))} +
+ )} +
+ +
+ +

+ ${spend.cost_today_usd.toFixed(2)} +

+

+ {compactNumber.format(spend.tokens_today)} tokens +

+
+ +

v{ship.version}

+

+ {ship.open_release_proposal + ? "Release proposal waiting" + : ship.ci_fix_tasks > 0 + ? `${ship.ci_fix_tasks} CI fix open` + : "No release pending"} +

+
+
+
+ ); +} diff --git a/roboco/api/routes/telegram.py b/roboco/api/routes/telegram.py index 2c7c6691..ab3fff30 100644 --- a/roboco/api/routes/telegram.py +++ b/roboco/api/routes/telegram.py @@ -23,6 +23,7 @@ from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role from roboco.api.schemas.telegram import ( TelegramCredentialsSetRequest, TelegramCredentialsStatus, + TelegramTodayResponse, TelegramWebAppAuthRequest, ) from roboco.config import settings @@ -34,6 +35,7 @@ from roboco.services.telegram_credentials import ( TelegramCredentialsValidationError, get_telegram_credentials_service, ) +from roboco.services.tg_cockpit import get_tg_cockpit_service from roboco.utils.telegram_initdata import validate_init_data if TYPE_CHECKING: @@ -84,6 +86,20 @@ async def set_telegram_credentials( return TelegramCredentialsStatus(has_credentials=has_creds) +@router.get("/today", response_model=TelegramTodayResponse) +@guard_deco.rate_limit(requests=30, window=60) +async def get_today_brief( + db: DbSession, agent: CurrentAgentContext +) -> TelegramTodayResponse: + """The Mini App's one-round-trip "Today" brief — needs-you items, fleet + snapshot, today's spend, ship state. DB-only by construction (see + ``TgCockpitService``).""" + require_ceo_role(agent.role, action="view the Today brief") + return TelegramTodayResponse.model_validate( + await get_tg_cockpit_service(db).today() + ) + + # ========================================================================== # Mini App sign-in — public, pre-auth. Conditionally mounted; see # ``mount_telegram_miniapp_auth`` below. diff --git a/roboco/api/schemas/telegram.py b/roboco/api/schemas/telegram.py index 233e4a3f..dd2c441c 100644 --- a/roboco/api/schemas/telegram.py +++ b/roboco/api/schemas/telegram.py @@ -1,5 +1,7 @@ """Schemas for the Telegram notifications bridge's CEO surface.""" +from datetime import datetime + from pydantic import BaseModel, Field @@ -25,3 +27,62 @@ class TelegramWebAppAuthRequest(BaseModel): """ init_data: str = Field(min_length=1, max_length=4096) + + +# ========================================================================== +# Mini App "Today" brief — the cockpit's one-round-trip home screen. +# ========================================================================== + + +class TodayTaskItem(BaseModel): + """One task row on the brief — just enough to recognize and jump.""" + + id: str + title: str + status: str + team: str | None = None + updated_at: datetime | None = None + + +class TodayNeedsYou(BaseModel): + """Everything currently waiting on the CEO, capped for a phone screen.""" + + total: int + awaiting_ceo_count: int + awaiting_ceo: list[TodayTaskItem] + blocked_count: int + blocked: list[TodayTaskItem] + held_drafts: dict[str, int] + + +class TodayFleetAgent(BaseModel): + name: str + role: str + team: str | None = None + task_title: str | None = None + + +class TodayFleet(BaseModel): + total: int + by_status: dict[str, int] + working: list[TodayFleetAgent] + + +class TodaySpend(BaseModel): + tokens_today: int + cost_today_usd: float + + +class TodayShip(BaseModel): + version: str + open_release_proposal: bool + ci_fix_tasks: int + + +class TelegramTodayResponse(BaseModel): + """The whole brief in one response — the Mini App opens on this.""" + + needs_you: TodayNeedsYou + fleet: TodayFleet + spend: TodaySpend + ship: TodayShip diff --git a/roboco/services/tg_cockpit.py b/roboco/services/tg_cockpit.py new file mode 100644 index 00000000..9b38d593 --- /dev/null +++ b/roboco/services/tg_cockpit.py @@ -0,0 +1,142 @@ +"""TgCockpitService — the Mini App's one-round-trip "Today" brief. + +Composes existing read paths (TaskService listers, DashboardService's agent +snapshot, UsageService's day rollup) into a single phone-sized payload +answering "does anything need me?". Deliberately DB-only and cheap: no live +GitHub calls, no release-readiness snapshot (that path clones + shells out to +git), no orchestrator singleton — the ship-state red/green proxy is the set +of open ci_watch fix tasks, which exists precisely when a watched repo went +red. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from sqlalchemy import select + +from roboco.config import settings +from roboco.db.tables import TaskTable +from roboco.foundation.policy.content import markers +from roboco.services.base import BaseService +from roboco.services.dashboard import get_dashboard_service +from roboco.services.task import get_task_service +from roboco.services.usage import get_usage_service + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.ext.asyncio import AsyncSession + + from roboco.services.task import TaskService + +# Phone-screen caps: the brief shows the top few and a count, never a feed. +_TASK_ITEM_CAP = 5 +_WORKING_AGENT_CAP = 8 + + +def _task_item(task: TaskTable) -> dict[str, Any]: + return { + "id": str(task.id), + "title": task.title, + "status": task.status.value if task.status else "", + "team": task.team.value if task.team else None, + "updated_at": task.updated_at or task.created_at, + } + + +class TgCockpitService(BaseService): + """Read-only aggregate behind ``GET /api/telegram/today``.""" + + service_name = "tg_cockpit" + + async def today(self) -> dict[str, Any]: + tasks = get_task_service(self.session) + needs_you = await self._needs_you(tasks) + return { + "needs_you": needs_you, + "fleet": await self._fleet(), + "spend": await self._spend(), + "ship": { + "version": settings.app_version, + "open_release_proposal": needs_you["held_drafts"]["release_proposals"] + > 0, + "ci_fix_tasks": len(await tasks.list_open_ci_watch_tasks()), + }, + } + + async def _needs_you(self, tasks: TaskService) -> dict[str, Any]: + awaiting = await tasks.list_awaiting_ceo_approval() + blocked = await tasks.list_blocked() + held = { + "release_proposals": len(await tasks.list_open_release_proposals()), + "x_posts": len(await tasks.list_open_x_posts()), + "video_posts": len(await tasks.list_open_video_post_drafts()), + "roadmap_items": await self._pending_roadmap_items(tasks), + } + return { + "total": len(awaiting) + len(blocked) + sum(held.values()), + "awaiting_ceo_count": len(awaiting), + "awaiting_ceo": [_task_item(t) for t in awaiting[:_TASK_ITEM_CAP]], + "blocked_count": len(blocked), + "blocked": [_task_item(t) for t in blocked[:_TASK_ITEM_CAP]], + "held_drafts": held, + } + + async def _pending_roadmap_items(self, tasks: TaskService) -> int: + """Proposed (still-undecided) items across every open roadmap cycle.""" + pending = 0 + for cycle in await tasks.list_open_roadmap_cycles(): + payload = markers.get_roadmap_cycle(cycle) or {} + pending += sum( + 1 + for item in payload.get("items") or [] + if item.get("status") == "proposed" + ) + return pending + + async def _fleet(self) -> dict[str, Any]: + snapshot = await get_dashboard_service(self.session).get_all_agent_status() + agents: list[dict[str, Any]] = snapshot.get("agents", []) + working = [a for a in agents if a.get("current_task_id")][:_WORKING_AGENT_CAP] + titles = await self._task_titles([a["current_task_id"] for a in working]) + return { + "total": snapshot.get("total", len(agents)), + "by_status": snapshot.get("by_status", {}), + "working": [ + { + "name": a.get("name", ""), + "role": a.get("role", ""), + "team": a.get("team"), + "task_title": titles.get(str(a["current_task_id"])), + } + for a in working + ], + } + + async def _task_titles(self, task_ids: list[Any]) -> dict[str, str]: + if not task_ids: + return {} + ids: list[UUID] = [tid for tid in task_ids if tid is not None] + result = await self.session.execute( + select(TaskTable.id, TaskTable.title).where(TaskTable.id.in_(ids)) + ) + return {str(row.id): row.title for row in result} + + async def _spend(self) -> dict[str, Any]: + # Mirrors the CEO dashboard overview: a usage hiccup degrades the + # brief to zeros instead of failing the whole endpoint. + try: + summary = await get_usage_service(self.session).get_today_summary() + return { + "tokens_today": int(summary.get("tokens_today", 0)), + "cost_today_usd": float(summary.get("cost_today_usd", 0.0)), + } + except Exception: # pragma: no cover - defensive degradation + self.log.warning("today-brief usage summary failed", exc_info=True) + return {"tokens_today": 0, "cost_today_usd": 0.0} + + +def get_tg_cockpit_service(session: AsyncSession) -> TgCockpitService: + """Construct a TgCockpitService bound to ``session``.""" + return TgCockpitService(session) diff --git a/tests/unit/services/test_tg_cockpit.py b/tests/unit/services/test_tg_cockpit.py new file mode 100644 index 00000000..886d5247 --- /dev/null +++ b/tests/unit/services/test_tg_cockpit.py @@ -0,0 +1,187 @@ +"""TgCockpitService coverage: the /telegram/today aggregate composes +needs-you counts, fleet snapshot, spend, and ship state from seeded rows — +and degrades to an all-zeros brief on an empty company. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from roboco.config import settings +from roboco.db.tables import AgentTable, TaskTable +from roboco.foundation import identity as _foundation +from roboco.foundation.policy.content import markers +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.task import ( + RELEASE_MANAGER_SOURCE, + ROADMAP_SOURCE, + VIDEO_POST_SOURCE, + X_POST_SOURCE, +) +from roboco.services.tg_cockpit import get_tg_cockpit_service + +if TYPE_CHECKING: + from uuid import UUID + + from sqlalchemy.ext.asyncio import AsyncSession + +SYSTEM_UUID = _foundation.AGENTS["system"].uuid + +CI_WATCH_SOURCE = "ci_watch" + +# 1 awaiting + 1 blocked + 4 held drafts (release/x/video/roadmap-item). +EXPECTED_NEEDS_YOU_TOTAL = 6 + + +async def _seed_system_agent(session: AsyncSession) -> None: + if await session.get(AgentTable, SYSTEM_UUID) is None: + session.add( + AgentTable( + id=SYSTEM_UUID, + name="system", + slug="system", + role=AgentRole.SYSTEM, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="x", + capabilities=[], + permissions={}, + metrics={}, + ) + ) + await session.flush() + + +def _task( + title: str, + task_status: TS, + *, + source: str | None = None, + team: Team = Team.BACKEND, +) -> TaskTable: + return TaskTable( + id=uuid4(), + title=title, + description="A description long enough to satisfy any length floor.", + acceptance_criteria=["it is visible on the Today brief"], + status=task_status, + priority=2, + task_type=TT.ADMINISTRATIVE, + nature=TN.NON_TECHNICAL, + estimated_complexity=Complexity.LOW, + created_by=SYSTEM_UUID, + team=team, + source=source, + confirmed_by_human=True, + ) + + +async def _seed_working_agent(session: AsyncSession, current_task_id: UUID) -> None: + session.add( + AgentTable( + id=uuid4(), + name="be-dev-1", + slug="be-dev-1", + role=AgentRole.DEVELOPER, + team=Team.BACKEND, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="x", + capabilities=[], + permissions={}, + metrics={}, + current_task_id=current_task_id, + ) + ) + await session.flush() + + +@pytest.mark.asyncio +async def test_today_is_all_zeros_on_an_empty_company( + db_session: AsyncSession, +) -> None: + brief = await get_tg_cockpit_service(db_session).today() + + assert brief["needs_you"]["total"] == 0 + assert brief["needs_you"]["awaiting_ceo"] == [] + assert brief["needs_you"]["blocked"] == [] + assert brief["fleet"]["working"] == [] + assert brief["spend"] == {"tokens_today": 0, "cost_today_usd": 0.0} + assert brief["ship"]["version"] == settings.app_version + assert brief["ship"]["open_release_proposal"] is False + assert brief["ship"]["ci_fix_tasks"] == 0 + + +@pytest.mark.asyncio +async def test_today_composes_needs_you_fleet_and_ship( + db_session: AsyncSession, +) -> None: + await _seed_system_agent(db_session) + + awaiting = _task("Root PR ready", TS.AWAITING_CEO_APPROVAL) + blocked = _task("Stuck on infra", TS.BLOCKED) + x_draft = _task("X draft", TS.PENDING, source=X_POST_SOURCE, team=Team.BOARD) + video_draft = _task( + "Video draft", TS.PENDING, source=VIDEO_POST_SOURCE, team=Team.BOARD + ) + release_prop = _task( + "Release 0.26.0", TS.PENDING, source=RELEASE_MANAGER_SOURCE, team=Team.BOARD + ) + ci_fix = _task("Fix red CI", TS.PENDING, source=CI_WATCH_SOURCE) + cycle = _task("Roadmap cycle", TS.PENDING, source=ROADMAP_SOURCE, team=Team.BOARD) + for row in ( + awaiting, + blocked, + x_draft, + video_draft, + release_prop, + ci_fix, + cycle, + ): + db_session.add(row) + await db_session.flush() + markers.set_roadmap_cycle( + cycle, + { + "goal": "g", + "items": [ + {"id": "item-0", "status": "proposed"}, + {"id": "item-1", "status": "approved"}, + ], + }, + ) + await _seed_working_agent(db_session, awaiting.id) + + brief = await get_tg_cockpit_service(db_session).today() + + needs = brief["needs_you"] + assert needs["awaiting_ceo_count"] == 1 + assert needs["awaiting_ceo"][0]["title"] == "Root PR ready" + assert needs["awaiting_ceo"][0]["status"] == "awaiting_ceo_approval" + assert needs["blocked_count"] == 1 + assert needs["held_drafts"] == { + "release_proposals": 1, + "x_posts": 1, + "video_posts": 1, + "roadmap_items": 1, + } + assert needs["total"] == EXPECTED_NEEDS_YOU_TOTAL + + working = brief["fleet"]["working"] + assert len(working) == 1 + assert working[0]["name"] == "be-dev-1" + assert working[0]["task_title"] == "Root PR ready" + + assert brief["ship"]["open_release_proposal"] is True + assert brief["ship"]["ci_fix_tasks"] == 1