+ {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