mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(tg): P1 — Today home tab + one-round-trip /telegram/today brief
Mini App V4 phase 1: the cockpit now opens on a "Today" brief answering "does anything need me?" in one glance. Backend: GET /api/telegram/today (CEO-gated, rate-limited) returns the whole brief in one round trip via the new TgCockpitService — needs-you items (awaiting-CEO + blocked tasks capped for a phone screen, held-draft counts across release/X/video/roadmap queues), a fleet snapshot with per-agent current-task titles, today's spend from the day rollup (degrading to zeros on a usage hiccup, mirroring the CEO overview), and ship state. Deliberately DB-only: no live GitHub calls, no readiness snapshot (that path clones), no orchestrator singleton — the CI red/green proxy is the set of open ci_watch fix tasks. Panel: TgTodayTab is the new default tab (Gauge icon) — needs-you rows and draft chips deep-link into the tab that acts on them (with a haptic tap), fleet/spend/ship render as dense cards, 45s refetch until the P3 WebSocket wiring lands.
This commit is contained in:
@@ -29,6 +29,9 @@ vi.mock("@/lib/api/client", () => ({
|
||||
vi.mock("@/components/tg/tg-tab-bar", () => ({
|
||||
TgTabBar: () => <div data-testid="tg-tab-bar" />,
|
||||
}));
|
||||
vi.mock("@/components/tg/tg-today-tab", () => ({
|
||||
TgTodayTab: () => <div data-testid="tg-today-tab" />,
|
||||
}));
|
||||
vi.mock("@/components/tg/tg-approvals-tab", () => ({
|
||||
TgApprovalsTab: () => <div data-testid="tg-approvals-tab" />,
|
||||
}));
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<BootstrapState>({ kind: "validating" });
|
||||
const [tab, setTab] = useState<TgTab>("approvals");
|
||||
const [tab, setTab] = useState<TgTab>("today");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -129,6 +130,7 @@ export default function TelegramMiniAppPage() {
|
||||
return (
|
||||
<TgWebAppProvider webApp={state.webApp}>
|
||||
<div className="p-3 pb-20">
|
||||
{tab === "today" && <TgTodayTab onNavigate={setTab} />}
|
||||
{tab === "approvals" && <TgApprovalsTab />}
|
||||
{tab === "inbox" && <TgInboxTab />}
|
||||
{tab === "board" && <TgBoardTab />}
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<TgTodayTab onNavigate={onNavigate} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onNavigate;
|
||||
}
|
||||
|
||||
function brief(overrides: Record<string, unknown> = {}) {
|
||||
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<string, number>;
|
||||
};
|
||||
fleet: {
|
||||
total: number;
|
||||
by_status: Record<string, number>;
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<section className="rounded-lg border bg-card p-3 text-card-foreground">
|
||||
<h2 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
onOpen,
|
||||
}: {
|
||||
task: TodayTaskItem;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1.5 text-left"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm leading-snug">{task.title}</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{task.team ?? "—"}
|
||||
{task.updated_at &&
|
||||
` · ${formatDistanceToNow(new Date(task.updated_at))} ago`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TodayBrief>({
|
||||
queryKey: ["tg-today"],
|
||||
queryFn: async () => (await api.get<TodayBrief>("/telegram/today")).data,
|
||||
refetchInterval: REFETCH_MS,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-center text-muted-foreground">
|
||||
<AlertTriangle className="h-8 w-8 opacity-50" />
|
||||
<p className="text-sm">Couldn't load the brief</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<SectionCard icon={CheckCircle2} title="Needs you">
|
||||
{needs.total === 0 ? (
|
||||
<p className="py-2 text-sm text-muted-foreground">
|
||||
All clear — nothing is waiting on you.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{heldEntries.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{heldEntries.map(([key, count]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => go("approvals")}
|
||||
className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{DRAFT_LABELS[key] ?? key} · {count}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{needs.awaiting_ceo.map((t) => (
|
||||
<TaskRow key={t.id} task={t} onOpen={() => go("board")} />
|
||||
))}
|
||||
{needs.blocked_count > 0 && (
|
||||
<p className="text-[11px] font-medium text-destructive">
|
||||
{needs.blocked_count} blocked
|
||||
</p>
|
||||
)}
|
||||
{needs.blocked.map((t) => (
|
||||
<TaskRow key={t.id} task={t} onOpen={() => go("board")} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard icon={Users} title="Fleet">
|
||||
<div className="mb-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>{fleet.total} agents</span>
|
||||
{Object.entries(fleet.by_status).map(([status, count]) => (
|
||||
<span key={status}>
|
||||
{count} {status}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{fleet.working.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No one is mid-task.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{fleet.working.map((agent) => (
|
||||
<li key={agent.name} className="text-sm leading-snug">
|
||||
<span className="font-medium">{agent.name}</span>
|
||||
{agent.task_title && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {agent.task_title}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SectionCard icon={CircleDollarSign} title="Spend today">
|
||||
<p className="text-lg font-semibold tabular-nums">
|
||||
${spend.cost_today_usd.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground tabular-nums">
|
||||
{compactNumber.format(spend.tokens_today)} tokens
|
||||
</p>
|
||||
</SectionCard>
|
||||
<SectionCard icon={Rocket} title="Ship">
|
||||
<p className="text-lg font-semibold tabular-nums">v{ship.version}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ship.open_release_proposal
|
||||
? "Release proposal waiting"
|
||||
: ship.ci_fix_tasks > 0
|
||||
? `${ship.ci_fix_tasks} CI fix open`
|
||||
: "No release pending"}
|
||||
</p>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user