mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
W6: Telegram notifications bridge (V1) (#524)
* feat(gateway): reviewer/PM collision map (W5)
The collision surface (intends_to_touch / adds_migration / touches_shared)
is authored at delegate time, consumed once by SequencingService to wire
dependency edges, then never shown to a reviewer again. This surfaces it:
- Pure builder (services/gateway/choreographer/collision.py): for a task
under review, the surfaced siblings (same parent) that would collide —
file-overlap globs or a shared migration chain (both adds_migration) —
with the overlapping globs and a declared-vs-actual drift check. No
DB/IO; callers fetch siblings (one indexed get_subtasks query, mig 069)
+ actual files (git). Caps: 10 siblings, 5 globs.
- Evidence envelopes: collision_context block injected into QA
claim_review, PR-gate claim_gate_review (both carry real touched files
so drift is populated), and the PM i_will_plan briefing (no actual
files at plan time, drift omitted). Best-effort — a failure omits the
block, never breaks the verb/briefing. Empty block omitted (zero token
cost via _EVIDENCE_OMIT_WHEN_EMPTY).
- Panel: GET /api/tasks/{id}/collision-map (declared surface + sibling
overlap; no drift — the panel route resolves no workspace) + a Collision
tab on the task detail (8th tab). Mock-mode returns an empty map.
- docs/map added to the RAG auto-index dirs so the collision-map concept
is fleet-retrievable; skipped gracefully if the dir is absent.
19 new tests (15 unit on the pure builder + 4 integration on the route).
Gate green: ruff/mypy/xenon (module rank A)/pytest 13000/coverage 94.81%,
panel typecheck/lint/516 tests.
* [w6-telegram] Add Telegram notifications bridge (V1)
CEO-facing Telegram DM bridge, flag-gated off by default
(ROBOCO_TELEGRAM_ENABLED). Mirrors the X-credentials / X-client pattern:
- TelegramCredentialsTable (migration 073) — singleton Fernet-encrypted
bot_token + chat_id, all-or-nothing set/clear; API never returns plaintext.
- TelegramClient ABC / NullTelegramClient (no-op, configured->False, never
raises) / LiveTelegramClient (httpx POST sendMessage) / build_telegram_client
factory (Null when creds unset).
- /telegram/credentials CEO-only routes (write-only, guard-decorated).
- Best-effort _notify_telegram fan-out from the two CEO-notify producers
(notify_ceo_of_escalation, notify_ceo_of_completion) — guarded by the flag,
never raises into the producer, carries a panel deep-link when
panel_base_url is set.
- panel credentials card (2 fields) nested in the Telegram feature-flag row.
- panel_base_url + telegram_timeout_seconds config fields.
V1 scope only: credentials + flag + panel card + client + one-line fan-out.
Out of scope (V2): inbound commands, a TelegramEngine background loop, a
dedup ledger, a bus subscription.
* [w6-telegram] fix: slave mypy/xenon regression (product tests + helper extract)
Pre-existing on slave from prior session's merges — no PR's CI caught them
(squash merges don't re-CI the result; each branch was based on older slave).
- test_product: _product helper returned MagicMock -> list invariant error;
cast to ProductTable, move import under TYPE_CHECKING.
- test_usage: svc.session.execute (AsyncSession) has no call_args_list;
cast to MagicMock at the two call sites.
- product.progress_for_products: xenon rank C -> extract module-level
_project_to_products_map helper (repo pattern: helper-extract).
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
"""Add the telegram_credentials table — the Telegram notifications bridge.
|
||||
|
||||
Singleton row (mirrors ``x_credentials``, migration 059) holding the
|
||||
Fernet-encrypted bot token (from @BotFather) + chat id (the CEO's
|
||||
destination). Additive and inert until real Telegram credentials are set and
|
||||
``telegram_enabled`` is armed.
|
||||
|
||||
Revision ID: 074_telegram_credentials
|
||||
Revises: 073_project_environments
|
||||
Create Date: 2026-07-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "074_telegram_credentials"
|
||||
down_revision = "073_project_environments"
|
||||
branch_labels: dict[str, str] | None = None
|
||||
depends_on: dict[str, str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"telegram_credentials",
|
||||
sa.Column("id", sa.UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("bot_token_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column("chat_id_encrypted", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("telegram_credentials")
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const { getCredentialsStatus, setCredentials } = vi.hoisted(() => ({
|
||||
getCredentialsStatus: vi.fn(async () => ({ has_credentials: false })),
|
||||
setCredentials: vi.fn(async () => ({ has_credentials: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
telegramApi: { getCredentialsStatus, setCredentials },
|
||||
}));
|
||||
|
||||
import { TelegramCredentialsForm } from "../telegram-credentials-card";
|
||||
|
||||
function withQueryClient(ui: ReactNode) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("TelegramCredentialsForm", () => {
|
||||
beforeEach(() => {
|
||||
getCredentialsStatus.mockClear();
|
||||
setCredentials.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows 'no credentials configured' by default", async () => {
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
expect(
|
||||
await screen.findByText("No credentials configured"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables Save until both fields are filled", async () => {
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
await screen.findByText("No credentials configured");
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Bot token (from @BotFather)"), {
|
||||
target: { value: "123:abc" },
|
||||
});
|
||||
expect(saveButton).toBeDisabled(); // chat id still unfilled
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Chat id (destination)"), {
|
||||
target: { value: "987" },
|
||||
});
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("saves both secrets and clears the inputs on success", async () => {
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
await screen.findByText("No credentials configured");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Bot token (from @BotFather)"), {
|
||||
target: { value: "123:abc" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Chat id (destination)"), {
|
||||
target: { value: "987" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCredentials).toHaveBeenCalledWith({
|
||||
bot_token: "123:abc",
|
||||
chat_id: "987",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(
|
||||
screen.getByLabelText("Bot token (from @BotFather)") as HTMLInputElement
|
||||
).value,
|
||||
).toBe(""),
|
||||
);
|
||||
});
|
||||
|
||||
it("a clear (both blank + has_credentials) opens a confirm dialog and defers the mutation until confirmed", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: "Save" });
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(setCredentials).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
await waitFor(() =>
|
||||
expect(setCredentials).toHaveBeenCalledWith({
|
||||
bot_token: "",
|
||||
chat_id: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a normal both-filled save fires immediately without a confirm dialog", async () => {
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
await screen.findByText("No credentials configured");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Bot token (from @BotFather)"), {
|
||||
target: { value: "123:abc" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Chat id (destination)"), {
|
||||
target: { value: "987" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCredentials).toHaveBeenCalledWith({
|
||||
bot_token: "123:abc",
|
||||
chat_id: "987",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a clear confirm dialog cancel does NOT fire the mutation", async () => {
|
||||
getCredentialsStatus.mockResolvedValueOnce({ has_credentials: true });
|
||||
render(withQueryClient(<TelegramCredentialsForm />));
|
||||
await screen.findByText("Credentials are set");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(setCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { XCredentialsForm } from "@/components/settings/x-credentials-card";
|
||||
import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card";
|
||||
import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -93,12 +94,15 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
|
||||
"Materialize a weekly org-report note (velocity, cycle time, rework, cost) in the vault's Reports/ folder and notify you — deterministic numbers, no LLM. Needs the Obsidian vault projection on.",
|
||||
vault_kb_enabled:
|
||||
"Index your own vault notes (default RoboCo/Notes/) into the knowledge base so the fleet can retrieve what you write — every note is screened for injection attempts before it's indexed. Needs the Obsidian vault projection on.",
|
||||
telegram_enabled:
|
||||
"Best-effort Telegram DMs to you alongside in-app notifications when a task is escalated for your approval or completes. Server-side fan-out — never blocks the in-app notification. Stays inert until you set bot-token + chat-id credentials in the Telegram card below.",
|
||||
};
|
||||
|
||||
export function FeatureFlagsCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [xCredsOpen, setXCredsOpen] = useState(false);
|
||||
const [tiktokCredsOpen, setTiktokCredsOpen] = useState(false);
|
||||
const [telegramCredsOpen, setTelegramCredsOpen] = useState(false);
|
||||
// Off-transition awaiting operator confirm. Null = no dialog open.
|
||||
const [confirmFlag, setConfirmFlag] = useState<FeatureFlag | null>(null);
|
||||
// Every in-flight toggle key — added on mutate, removed on settle. Tracks
|
||||
@@ -170,12 +174,13 @@ export function FeatureFlagsCard() {
|
||||
{flags.map((flag) => {
|
||||
const isXEngine = flag.key === "x_engine_enabled";
|
||||
const isVideoEngine = flag.key === "video_engine_enabled";
|
||||
const isTelegram = flag.key === "telegram_enabled";
|
||||
return (
|
||||
<div
|
||||
key={flag.key}
|
||||
className={cn(
|
||||
"rounded-lg border p-4",
|
||||
(isXEngine || isVideoEngine) && "md:col-span-2",
|
||||
(isXEngine || isVideoEngine || isTelegram) && "md:col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
@@ -251,6 +256,31 @@ export function FeatureFlagsCard() {
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
{isTelegram && (
|
||||
<Collapsible
|
||||
open={telegramCredsOpen}
|
||||
onOpenChange={setTelegramCredsOpen}
|
||||
className="mt-3"
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-between px-2 text-muted-foreground"
|
||||
>
|
||||
<span className="text-sm">Telegram credentials</span>
|
||||
{telegramCredsOpen ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<TelegramCredentialsForm />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { telegramApi } from "@/lib/api";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Key, KeyRound, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const FIELDS: Array<{ key: "bot_token" | "chat_id"; label: string }> = [
|
||||
{ key: "bot_token", label: "Bot token (from @BotFather)" },
|
||||
{ key: "chat_id", label: "Chat id (destination)" },
|
||||
];
|
||||
|
||||
// The CEO's one-time entry of the Telegram bot token + destination chat id.
|
||||
// Write-only — the stored values are never displayed back, only whether
|
||||
// they're set (mirrors the X / git-token cards). Both are required together:
|
||||
// a token alone can't target a DM. Rendered chrome-less so it can nest inside
|
||||
// the Telegram feature-flag row.
|
||||
export function TelegramCredentialsForm() {
|
||||
const queryClient = useQueryClient();
|
||||
const [values, setValues] = useState({ bot_token: "", chat_id: "" });
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ["telegram", "credentials"],
|
||||
queryFn: () => telegramApi.getCredentialsStatus(),
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => telegramApi.setCredentials(values),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["telegram", "credentials"] });
|
||||
setValues({ bot_token: "", chat_id: "" });
|
||||
toast.success("Telegram credentials saved");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
`Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const allFilled = FIELDS.every((f) => values[f.key].trim().length > 0);
|
||||
const noneFilled = FIELDS.every((f) => values[f.key].trim().length === 0);
|
||||
const canSave = allFilled || (noneFilled && !!status?.has_credentials);
|
||||
const isClearing = noneFilled && !!status?.has_credentials;
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The bot token from <span className="font-medium">@BotFather</span> and the
|
||||
chat id to DM (your user/channel id). Stored encrypted server-side; agents
|
||||
never see them and this panel never displays them again once saved.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 rounded-md border p-3">
|
||||
{status?.has_credentials ? (
|
||||
<>
|
||||
<Key className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Credentials are set
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KeyRound className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-600 dark:text-amber-400">
|
||||
{isLoading ? "Checking..." : "No credentials configured"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{FIELDS.map((field) => (
|
||||
<div key={field.key} className="space-y-2">
|
||||
<Label htmlFor={`tg-cred-${field.key}`}>
|
||||
{status?.has_credentials ? `Replace ${field.label}` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={`tg-cred-${field.key}`}
|
||||
type="password"
|
||||
value={values[field.key]}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({ ...prev, [field.key]: e.target.value }))
|
||||
}
|
||||
placeholder="••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set both to save (or rotate); leave both blank and save to clear.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
isClearing ? setConfirmClear(true) : saveMutation.mutate()
|
||||
}
|
||||
disabled={saveMutation.isPending || !canSave}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{saveMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
<AlertDialog
|
||||
open={confirmClear}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setConfirmClear(false);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear Telegram credentials?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will clear the stored bot token and chat id. This cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setConfirmClear(false);
|
||||
saveMutation.mutate();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export { TabCommits } from "./tab-commits";
|
||||
export { TabNotes } from "./tab-notes";
|
||||
export { TabDependencies } from "./tab-dependencies";
|
||||
export { TabFindings } from "./tab-findings";
|
||||
export { TabCollision } from "./tab-collision";
|
||||
export { AcceptanceCriteria } from "./acceptance-criteria";
|
||||
export { ProgressTimeline } from "./progress-timeline";
|
||||
export { CheckpointCard } from "./checkpoint-card";
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Task } from "@/types";
|
||||
import { useTaskCollisionMap } from "@/hooks/use-tasks";
|
||||
import type { CollisionMap, CollisionSibling } from "@/lib/api/tasks";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { GitBranch, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface TabCollisionProps {
|
||||
task: Task;
|
||||
}
|
||||
|
||||
// Status → tailwind badge class (a small inline map; no shared helper exists
|
||||
// across the task-detail tabs, so this mirrors tab-findings' inline maps).
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
pending: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
||||
claimed:
|
||||
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
|
||||
in_progress:
|
||||
"bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
|
||||
awaiting_qa:
|
||||
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
|
||||
awaiting_documentation:
|
||||
"bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300",
|
||||
awaiting_pr_review:
|
||||
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
awaiting_pm_review:
|
||||
"bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300",
|
||||
awaiting_ceo_approval:
|
||||
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
|
||||
needs_revision:
|
||||
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
completed:
|
||||
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
|
||||
cancelled:
|
||||
"bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
|
||||
blocked:
|
||||
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
|
||||
paused: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
|
||||
verifying:
|
||||
"bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300",
|
||||
backlog: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500",
|
||||
};
|
||||
|
||||
function SiblingCard({ sib }: { sib: CollisionSibling }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="text-xs font-mono text-muted-foreground">
|
||||
{sib.id}
|
||||
</code>
|
||||
{sib.title && (
|
||||
<span className="text-sm font-medium truncate">{sib.title}</span>
|
||||
)}
|
||||
<Badge
|
||||
className={STATUS_CLASS[sib.status] ?? STATUS_CLASS.pending}
|
||||
>
|
||||
{sib.status}
|
||||
</Badge>
|
||||
{sib.branch_name && (
|
||||
<code className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{sib.branch_name}
|
||||
</code>
|
||||
)}
|
||||
{sib.pr_number != null && (
|
||||
<Badge variant="outline">#{sib.pr_number}</Badge>
|
||||
)}
|
||||
{sib.adds_migration && (
|
||||
<Badge variant="outline" className="text-amber-700">
|
||||
+migration
|
||||
</Badge>
|
||||
)}
|
||||
{sib.touches_shared && (
|
||||
<Badge variant="outline" className="text-orange-700">
|
||||
shared
|
||||
</Badge>
|
||||
)}
|
||||
{sib.sequence != null && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
seq {sib.sequence}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sib.intends_to_touch.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sib.intends_to_touch.map((g) => (
|
||||
<code
|
||||
key={g}
|
||||
className={
|
||||
"text-xs rounded px-1.5 py-0.5 " +
|
||||
(sib.overlap.includes(g)
|
||||
? "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
|
||||
: "bg-muted text-muted-foreground")
|
||||
}
|
||||
>
|
||||
{g}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sib.overlap.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||
Overlaps your declared surface on{" "}
|
||||
{sib.overlap.join(", ")}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sib.undeclared.length > 0 && (
|
||||
<div className="rounded border border-amber-300 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40 p-2 text-xs">
|
||||
<span className="text-amber-700 dark:text-amber-300 font-medium">
|
||||
Drift — touched but not declared:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{sib.undeclared.map((f) => (
|
||||
<code
|
||||
key={f}
|
||||
className="bg-amber-100 dark:bg-amber-900 text-amber-800 dark:text-amber-200 rounded px-1.5 py-0.5"
|
||||
>
|
||||
{f}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabCollision({ task }: TabCollisionProps) {
|
||||
const { data, isLoading } = useTaskCollisionMap(task.id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No parent = a root; no collision siblings by construction.
|
||||
if (!data || data.parent_task_id == null) {
|
||||
return (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<GitBranch className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<p>No collision map — this task has no parent.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Roots and standalone tasks have no siblings to collide with.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const siblings = data.siblings;
|
||||
|
||||
if (siblings.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DeclaredSurfaceCard data={data} />
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<GitBranch className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<p>No colliding siblings.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
None of this task's siblings share its declared surface or
|
||||
migration chain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DeclaredSurfaceCard data={data} />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
{siblings.length} colliding sibling{siblings.length > 1 ? "s" : ""}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{siblings.map((sib) => (
|
||||
<SiblingCard key={sib.id} sib={sib} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeclaredSurfaceCard({ data }: { data: CollisionMap }) {
|
||||
const hasSurface =
|
||||
data.intends_to_touch.length > 0 ||
|
||||
data.adds_migration ||
|
||||
data.touches_shared;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<h3 className="text-sm font-semibold">Declared surface</h3>
|
||||
{hasSurface ? (
|
||||
<>
|
||||
{data.intends_to_touch.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.intends_to_touch.map((g) => (
|
||||
<code
|
||||
key={g}
|
||||
className="text-xs bg-muted text-muted-foreground rounded px-1.5 py-0.5"
|
||||
>
|
||||
{g}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{data.adds_migration && (
|
||||
<Badge variant="outline" className="text-amber-700">
|
||||
adds migration
|
||||
</Badge>
|
||||
)}
|
||||
{data.touches_shared && (
|
||||
<Badge variant="outline" className="text-orange-700">
|
||||
touches shared
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This task declared no collision surface.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { TabCommits } from "./tab-commits";
|
||||
import { TabNotes } from "./tab-notes";
|
||||
import { TabDependencies } from "./tab-dependencies";
|
||||
import { TabFindings } from "./tab-findings";
|
||||
import { TabCollision } from "./tab-collision";
|
||||
import {
|
||||
FileText,
|
||||
Layout,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
StickyNote,
|
||||
Link2,
|
||||
ListChecks,
|
||||
GitBranch,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -108,6 +110,12 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
hint: "Revision-findings ledger — QA / PR-review / PM / CEO bounce feedback",
|
||||
count: findingsCount > 0 ? findingsCount : undefined,
|
||||
},
|
||||
{
|
||||
value: "collision",
|
||||
label: "Collision",
|
||||
icon: GitBranch,
|
||||
hint: "Sibling collision surface + declared-vs-actual drift",
|
||||
},
|
||||
];
|
||||
|
||||
// The active tab lives in the URL (?tab=) so it survives reloads,
|
||||
@@ -128,7 +136,7 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="mt-6">
|
||||
<TabsList className="grid w-full grid-cols-7 lg:w-auto lg:inline-grid">
|
||||
<TabsList className="grid w-full grid-cols-8 lg:w-auto lg:inline-grid">
|
||||
{tabs.map((tab) => (
|
||||
<Tooltip key={tab.value}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -169,6 +177,9 @@ export function TaskTabs({ task }: TaskTabsProps) {
|
||||
<TabsContent value="findings">
|
||||
<TabFindings task={task} />
|
||||
</TabsContent>
|
||||
<TabsContent value="collision">
|
||||
<TabCollision task={task} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { tasksApi, type TaskFilters } from "@/lib/api/tasks";
|
||||
import { tasksApi, type TaskFilters, type CollisionMap } from "@/lib/api/tasks";
|
||||
import {
|
||||
Team,
|
||||
TaskStatus,
|
||||
@@ -29,6 +29,7 @@ export const taskKeys = {
|
||||
[...taskKeys.all, "subtasks", parentId] as const,
|
||||
boardReview: (id: string) => [...taskKeys.all, "board-review", id] as const,
|
||||
findings: (id: string) => [...taskKeys.all, "findings", id] as const,
|
||||
collisionMap: (id: string) => [...taskKeys.all, "collision-map", id] as const,
|
||||
stats: () => [...taskKeys.all, "stats"] as const,
|
||||
statsByTeam: () => [...taskKeys.all, "stats-by-team"] as const,
|
||||
};
|
||||
@@ -81,6 +82,18 @@ export function useTaskFindings(taskId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The reviewer/PM collision map — surfaced siblings that would collide with
|
||||
// the task. Fetched lazily (enabled only on the Collision tab) so it adds no
|
||||
// cost to the task-detail load; mirrors useBoardReview's staleTime posture.
|
||||
export function useTaskCollisionMap(taskId: string) {
|
||||
return useQuery<CollisionMap>({
|
||||
queryKey: taskKeys.collisionMap(taskId),
|
||||
queryFn: () => tasksApi.getCollisionMap(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubtasks(parentTaskId: string) {
|
||||
return useQuery({
|
||||
queryKey: taskKeys.subtasks(parentTaskId),
|
||||
|
||||
@@ -31,6 +31,8 @@ export type {
|
||||
XPostHistoryEntry,
|
||||
XCredentialsStatus,
|
||||
} from "./x";
|
||||
export { telegramApi } from "./telegram";
|
||||
export type { TelegramCredentialsStatus } from "./telegram";
|
||||
export { roadmapApi } from "./roadmap";
|
||||
export type {
|
||||
RoadmapCycle,
|
||||
|
||||
@@ -75,6 +75,34 @@ export interface TaskFindingsResponse {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
// One surfaced sibling that collides with the task under review — matches
|
||||
// the backend CollisionSibling schema (roboco/api/schemas/tasks.py).
|
||||
export interface CollisionSibling {
|
||||
id: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
branch_name: string | null;
|
||||
pr_number: number | null;
|
||||
sequence: number | null;
|
||||
intends_to_touch: string[];
|
||||
adds_migration: boolean;
|
||||
touches_shared: boolean;
|
||||
overlap: string[];
|
||||
undeclared: string[];
|
||||
}
|
||||
|
||||
// The collision map for a task — its own declared surface + the surfaced
|
||||
// siblings (same parent) that would collide with it. Matches
|
||||
// CollisionMapResponse.
|
||||
export interface CollisionMap {
|
||||
task_id: string;
|
||||
parent_task_id: string | null;
|
||||
intends_to_touch: string[];
|
||||
adds_migration: boolean;
|
||||
touches_shared: boolean;
|
||||
siblings: CollisionSibling[];
|
||||
}
|
||||
|
||||
// Wire shape of GET /tasks/summary (backend TaskSummaryResponse) — exactly
|
||||
// the fields list views render; everything fat stays on GET /tasks/{id}.
|
||||
interface TaskSummaryWire {
|
||||
@@ -213,6 +241,26 @@ export const tasksApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
// The reviewer/PM collision map — the task's own declared surface + the
|
||||
// surfaced siblings (same parent) that would collide with it (file-overlap
|
||||
// globs or a shared migration chain). Empty siblings for a root or a task
|
||||
// with no colliding siblings. Feed for the panel's Collision tab.
|
||||
getCollisionMap: async (taskId: string): Promise<CollisionMap> => {
|
||||
if (isMockMode())
|
||||
return {
|
||||
task_id: taskId,
|
||||
parent_task_id: null,
|
||||
intends_to_touch: [],
|
||||
adds_migration: false,
|
||||
touches_shared: false,
|
||||
siblings: [],
|
||||
};
|
||||
const { data } = await api.get<CollisionMap>(
|
||||
"/tasks/" + taskId + "/collision-map",
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create task
|
||||
create: async (task: TaskCreate): Promise<Task> => {
|
||||
if (isMockMode()) {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import api from "./client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Telegram notifications bridge — the CEO's bot-token + chat-id credentials
|
||||
// (write-only; the API never returns the stored secrets). The fan-out itself
|
||||
// runs server-side from the CEO-notify producers; this surface is the
|
||||
// credentials card only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TelegramCredentialsStatus {
|
||||
has_credentials: boolean;
|
||||
}
|
||||
|
||||
export const telegramApi = {
|
||||
getCredentialsStatus: async (): Promise<TelegramCredentialsStatus> => {
|
||||
const { data } =
|
||||
await api.get<TelegramCredentialsStatus>("/telegram/credentials");
|
||||
return data;
|
||||
},
|
||||
setCredentials: async (creds: {
|
||||
bot_token: string;
|
||||
chat_id: string;
|
||||
}): Promise<TelegramCredentialsStatus> => {
|
||||
const { data } = await api.post<TelegramCredentialsStatus>(
|
||||
"/telegram/credentials",
|
||||
creds,
|
||||
);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -223,6 +223,10 @@ select = [
|
||||
# signature for keyword-argument compatibility (mypy override check), but the
|
||||
# stub bodies are empty — ARG002 would require renaming them, which breaks mypy.
|
||||
"tests/unit/services/test_optimal_grounding.py" = ["ARG002"]
|
||||
# Collision-builder test helpers mirror the builder's many keyword inputs
|
||||
# (parent/project/intends/migration/shared/sequence) — bundling them would
|
||||
# hurt readability more than the arg count hurts.
|
||||
"tests/unit/gateway/test_collision_context.py" = ["PLR0913"]
|
||||
|
||||
# =============================================================================
|
||||
# MyPy Configuration
|
||||
|
||||
@@ -45,6 +45,7 @@ from roboco.api.routes.settings import router as settings_router
|
||||
from roboco.api.routes.stream import router as stream_router
|
||||
from roboco.api.routes.system import router as system_router
|
||||
from roboco.api.routes.tasks import router as tasks_router
|
||||
from roboco.api.routes.telegram import router as telegram_router
|
||||
from roboco.api.routes.usage import router as usage_router
|
||||
from roboco.api.routes.v1 import do as do_module
|
||||
from roboco.api.routes.v1 import flow_auditor as flow_auditor_module
|
||||
@@ -478,6 +479,14 @@ def create_app() -> FastAPI:
|
||||
tags=["TikTok"],
|
||||
)
|
||||
|
||||
# Telegram notifications bridge — CEO-managed bot-token + chat-id credentials
|
||||
# (write-only); the fan-out itself runs server-side from the CEO producers.
|
||||
app.include_router(
|
||||
telegram_router,
|
||||
prefix=f"{api_prefix}/telegram",
|
||||
tags=["Telegram"],
|
||||
)
|
||||
|
||||
# Pitches — Board proposals + CEO approve -> auto-provision origination path.
|
||||
app.include_router(
|
||||
pitch_router,
|
||||
|
||||
@@ -23,6 +23,8 @@ from roboco.api.schemas.tasks import (
|
||||
CancelTaskRequest,
|
||||
CheckpointRequest,
|
||||
ClaimRequest,
|
||||
CollisionMapResponse,
|
||||
CollisionSibling,
|
||||
CommitRequest,
|
||||
CompleteTaskRequest,
|
||||
EscalateRequest,
|
||||
@@ -65,6 +67,7 @@ from roboco.services.base import (
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
)
|
||||
from roboco.services.gateway.choreographer.collision import build_collision_context
|
||||
from roboco.services.journal import get_journal_service
|
||||
from roboco.services.notification_delivery import (
|
||||
EscalationError,
|
||||
@@ -1348,6 +1351,59 @@ async def get_task_findings(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}/collision-map", response_model=CollisionMapResponse)
|
||||
async def get_task_collision_map(
|
||||
task_id: UUID,
|
||||
db: DbSession,
|
||||
_agent: CurrentAgentContext,
|
||||
) -> CollisionMapResponse:
|
||||
"""The reviewer/PM collision map for a task — its own declared surface
|
||||
(``intends_to_touch`` / ``adds_migration`` / ``touches_shared``) plus
|
||||
the surfaced siblings (same parent) that would collide with it: file
|
||||
globs that overlap or a shared migration chain. Read-only feed for the
|
||||
panel's Collision tab; the QA/PR-gate evidence envelopes carry the same
|
||||
block inline (with declared-vs-actual drift, which needs the real
|
||||
touched files the panel route doesn't resolve a workspace for).
|
||||
"""
|
||||
service = get_task_service(db)
|
||||
task = await service.get(task_id)
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
|
||||
)
|
||||
siblings = (
|
||||
await service.get_subtasks(UUID(str(task.parent_task_id)))
|
||||
if task.parent_task_id
|
||||
else []
|
||||
)
|
||||
# No actual files here — the panel shows the declared surface + sibling
|
||||
# overlap only; drift stays in the in-context evidence envelope.
|
||||
ctx = build_collision_context(task=task, siblings=siblings)
|
||||
return CollisionMapResponse(
|
||||
task_id=str(task.id),
|
||||
parent_task_id=str(task.parent_task_id) if task.parent_task_id else None,
|
||||
intends_to_touch=list(task.intends_to_touch or []),
|
||||
adds_migration=bool(task.adds_migration),
|
||||
touches_shared=bool(task.touches_shared),
|
||||
siblings=[
|
||||
CollisionSibling(
|
||||
id=s["id"],
|
||||
title=s.get("title"),
|
||||
status=s.get("status", ""),
|
||||
branch_name=s.get("branch_name"),
|
||||
pr_number=s.get("pr_number"),
|
||||
sequence=s.get("sequence"),
|
||||
intends_to_touch=s.get("intends_to_touch", []),
|
||||
adds_migration=s.get("adds_migration", False),
|
||||
touches_shared=s.get("touches_shared", False),
|
||||
overlap=s.get("overlap", []),
|
||||
undeclared=s.get("undeclared", []),
|
||||
)
|
||||
for s in (ctx or [])
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LIFECYCLE ENDPOINTS
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Telegram notifications bridge API — CEO-managed credentials (write-only).
|
||||
|
||||
The bridge itself is a server-side fan-out from the CEO-notify producers; the
|
||||
only surface here is the credentials card. CEO-only; credentials are
|
||||
write-only (the API never returns plaintext, mirroring ``/x/credentials``).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from roboco.api.deps import CurrentAgentContext, DbSession, require_ceo_role
|
||||
from roboco.api.schemas.telegram import (
|
||||
TelegramCredentialsSetRequest,
|
||||
TelegramCredentialsStatus,
|
||||
)
|
||||
from roboco.security import guard_deco
|
||||
from roboco.services.telegram_credentials import (
|
||||
TelegramCredentialsValidationError,
|
||||
get_telegram_credentials_service,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_ceo(agent: CurrentAgentContext) -> None:
|
||||
require_ceo_role(agent.role, action="manage Telegram credentials")
|
||||
|
||||
|
||||
@router.get("/credentials", response_model=TelegramCredentialsStatus)
|
||||
async def get_telegram_credentials(
|
||||
db: DbSession, agent: CurrentAgentContext
|
||||
) -> TelegramCredentialsStatus:
|
||||
"""Whether the bot token + chat id are stored. Never the secrets."""
|
||||
_require_ceo(agent)
|
||||
has_creds = await get_telegram_credentials_service(db).has_credentials()
|
||||
return TelegramCredentialsStatus(has_credentials=has_creds)
|
||||
|
||||
|
||||
@router.post("/credentials", response_model=TelegramCredentialsStatus)
|
||||
@guard_deco.rate_limit(requests=10, window=60)
|
||||
@guard_deco.max_request_size(size_bytes=8192)
|
||||
@guard_deco.block_clouds()
|
||||
@guard_deco.content_type_filter(["application/json"])
|
||||
@guard_deco.honeypot_detection(["email", "phone", "website"])
|
||||
@guard_deco.usage_monitor(max_calls=30, window=3600)
|
||||
async def set_telegram_credentials(
|
||||
data: TelegramCredentialsSetRequest, db: DbSession, agent: CurrentAgentContext
|
||||
) -> TelegramCredentialsStatus:
|
||||
"""Set (or, passing both empty, clear) the bot token + chat id together."""
|
||||
_require_ceo(agent)
|
||||
svc = get_telegram_credentials_service(db)
|
||||
try:
|
||||
has_creds = await svc.set_credentials(
|
||||
bot_token=data.bot_token,
|
||||
chat_id=data.chat_id,
|
||||
)
|
||||
except TelegramCredentialsValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
) from e
|
||||
await db.commit()
|
||||
return TelegramCredentialsStatus(has_credentials=has_creds)
|
||||
@@ -681,6 +681,46 @@ class TaskFindingsResponse(BaseModel):
|
||||
truncated: bool
|
||||
|
||||
|
||||
class CollisionSibling(BaseModel):
|
||||
"""One surfaced sibling that collides with the task under review.
|
||||
|
||||
The reviewer/PM collision map entry: the sibling's status/branch/PR +
|
||||
declared surface + the overlap globs (the sibling globs that collide
|
||||
with the task's declared globs) + declared-vs-actual drift (actual
|
||||
files the task touched that no declared glob covers — present only on
|
||||
the QA/PR-gate evidence path where the real touched files are known).
|
||||
"""
|
||||
|
||||
id: str
|
||||
title: str | None = None
|
||||
status: str
|
||||
branch_name: str | None = None
|
||||
pr_number: int | None = None
|
||||
sequence: int | None = None
|
||||
intends_to_touch: list[str] = []
|
||||
adds_migration: bool = False
|
||||
touches_shared: bool = False
|
||||
overlap: list[str] = []
|
||||
undeclared: list[str] = []
|
||||
|
||||
|
||||
class CollisionMapResponse(BaseModel):
|
||||
"""The collision map for a task — its own declared surface + the
|
||||
surfaced siblings (same parent) that would collide with it.
|
||||
|
||||
``siblings`` is empty for a root (no parent) or a task whose siblings
|
||||
don't collide. The panel tab renders this read-only; the QA/PR-gate
|
||||
evidence envelopes carry the same block (with drift) inline.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
parent_task_id: str | None = None
|
||||
intends_to_touch: list[str] = []
|
||||
adds_migration: bool = False
|
||||
touches_shared: bool = False
|
||||
siblings: list[CollisionSibling] = []
|
||||
|
||||
|
||||
def convert_plan(plan_data: dict | None) -> TaskPlanResponse | None:
|
||||
"""Convert plan JSON dict to TaskPlanResponse.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Schemas for the Telegram notifications bridge's CEO surface."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TelegramCredentialsStatus(BaseModel):
|
||||
"""Whether the bot token + chat id are stored. Never the secrets themselves."""
|
||||
|
||||
has_credentials: bool
|
||||
|
||||
|
||||
class TelegramCredentialsSetRequest(BaseModel):
|
||||
"""Set (or, if both are empty, clear) the bot token + chat id together."""
|
||||
|
||||
bot_token: str = Field(default="")
|
||||
chat_id: str = Field(default="")
|
||||
@@ -1505,6 +1505,31 @@ class Settings(BaseSettings):
|
||||
description="Public base URL for commit-trailer links",
|
||||
)
|
||||
|
||||
# Telegram notifications bridge — best-effort DMs to the CEO on escalation +
|
||||
# completion. Default-off; sending requires stored credentials AND
|
||||
# telegram_enabled. Server-side fan-out, never raises into the producer.
|
||||
telegram_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Master switch for the Telegram notifications bridge. OFF by "
|
||||
"default; when off no Telegram API call is ever made. Even when "
|
||||
"on, sending requires stored bot-token + chat-id credentials."
|
||||
),
|
||||
)
|
||||
telegram_timeout_seconds: float = Field(
|
||||
default=10.0,
|
||||
ge=1.0,
|
||||
description="Timeout (seconds) for a Telegram Bot API sendMessage call.",
|
||||
)
|
||||
panel_base_url: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"External panel base URL for Telegram message deep-links. Empty "
|
||||
"omits the link; e.g. https://panel.example.com -> "
|
||||
".../tasks/<id8>."
|
||||
),
|
||||
)
|
||||
|
||||
# Gateway coordination thresholds
|
||||
# Single source of truth for "claim heartbeat is stale", consumed via
|
||||
# `claimant_lock.is_stale` wherever a claim's freshness gates an action
|
||||
|
||||
@@ -2361,6 +2361,27 @@ class XCredentialsTable(Base):
|
||||
)
|
||||
|
||||
|
||||
class TelegramCredentialsTable(Base):
|
||||
"""Singleton row holding the Fernet-encrypted Telegram bot token + chat id
|
||||
(mirrors ``XCredentialsTable``). At most one row ever exists;
|
||||
``TelegramCredentialsService`` upserts it. Decrypted only server-side, by
|
||||
``telegram_client`` — the API never returns plaintext."""
|
||||
|
||||
__tablename__ = "telegram_credentials"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
bot_token_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
chat_id_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class XSeenMentionTable(Base):
|
||||
"""Dedup ledger for the mentions poll — one row per mention id the engine
|
||||
has ever turned into a held reply proposal (or decided to skip). Never
|
||||
|
||||
@@ -32,6 +32,7 @@ from roboco.services.content_notes import apply_structured_note
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
from roboco.services.gateway.choreographer._protocol import actor_context_fields
|
||||
from roboco.services.gateway.choreographer._verb_runner import VerbRunner
|
||||
from roboco.services.gateway.choreographer.collision import build_collision_context
|
||||
from roboco.services.gateway.claim_guards import (
|
||||
already_active_guard,
|
||||
paused_tasks_guard,
|
||||
@@ -969,6 +970,13 @@ class Choreographer:
|
||||
# can tell "searched, nothing" (below_floor / empty) from "search broke"
|
||||
# (error) from "subsystem off" (disabled); lessons is empty unless ok.
|
||||
briefing = {**briefing, "institutional_memory": memory_block}
|
||||
# The collision map: surfaced siblings (same parent) that would collide
|
||||
# with this task. A PM planning a batch root sees the other
|
||||
# root-subtasks' declared surfaces; a dev claiming a leaf sees its
|
||||
# cell-sibling overlaps. No actual files at plan/claim time, so drift is
|
||||
# omitted here (the QA/gate envelopes carry it). Best-effort — a failure
|
||||
# omits the block, never breaks the briefing.
|
||||
briefing = await self._with_collision_briefing(briefing, full, task)
|
||||
if include_ac_coverage and task_id is not None:
|
||||
coverage = await self.task.parent_ac_coverage(task_id)
|
||||
if coverage:
|
||||
@@ -985,6 +993,42 @@ class Choreographer:
|
||||
}
|
||||
return briefing
|
||||
|
||||
async def _collision_context_for(
|
||||
self, t: Any, *, actual_files: list[str] | None = None
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""The collision map for ``t`` against its surfaced siblings — one
|
||||
indexed ``get_subtasks(parent_task_id)`` query + the pure builder.
|
||||
|
||||
``None`` for a root (no parent) or a fetch failure (best-effort: the
|
||||
briefing / envelope omit the block rather than break). The QA and
|
||||
PR-gate evidence builders call the pure ``build_collision_context``
|
||||
directly (they hold the real touched files); this helper covers the
|
||||
planning/claim briefing path, which has no actual files yet.
|
||||
"""
|
||||
parent_id = getattr(t, "parent_task_id", None)
|
||||
if not parent_id:
|
||||
return None
|
||||
try:
|
||||
siblings = await self.task.get_subtasks(parent_id)
|
||||
except Exception: # best-effort: omit on any fetch failure
|
||||
return None
|
||||
return build_collision_context(
|
||||
task=t, siblings=siblings, actual_files=actual_files
|
||||
)
|
||||
|
||||
async def _with_collision_briefing(
|
||||
self, briefing: dict[str, Any], full: bool, task: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Merge the collision block into ``briefing`` when the planning/claim
|
||||
path warrants it (``full`` + a real task). Keeps the branch count out of
|
||||
``_briefing_for`` — the collision fetch + merge live here."""
|
||||
if not full or task is None:
|
||||
return briefing
|
||||
collision = await self._collision_context_for(task)
|
||||
if not collision:
|
||||
return briefing
|
||||
return {**briefing, "collision_context": collision}
|
||||
|
||||
async def _resolve_company_goals(
|
||||
self,
|
||||
heavy: dict[str, Any],
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Pure assembly of the reviewer/PM "collision map" block.
|
||||
|
||||
The collision surface (``intends_to_touch`` / ``adds_migration`` /
|
||||
``touches_shared``) is authored at delegate time and consumed once by
|
||||
``SequencingService`` to wire dependency edges, then never shown to a
|
||||
reviewer again. This module surfaces it: for a task under review, the
|
||||
siblings that share its parent and would collide — file-overlap siblings
|
||||
plus migration-chain siblings (both ``adds_migration`` in the same repo) —
|
||||
with the overlapping globs and a declared-vs-actual drift check.
|
||||
|
||||
Pure (no DB, no IO): callers fetch the task + its siblings (one indexed
|
||||
``get_subtasks(parent_task_id)`` query, mig 069) and the task's actual
|
||||
touched files (from git, where available), then hand them here. The same
|
||||
builder feeds the QA ``claim_review`` evidence, the PR-gate
|
||||
``claim_gate_review`` evidence, the PM planning briefing, and the panel's
|
||||
``GET /api/tasks/{id}/collision-map`` endpoint.
|
||||
|
||||
A sibling is shown when the analyzer would wire an edge between it and the
|
||||
task under review: file globs overlap, OR both add a migration (the
|
||||
Alembic-head collision needs no file overlap). ``touches_shared`` alone is
|
||||
too broad — a shared surface only collides with a sibling that also touches
|
||||
the same files (rule 3), so it rides the file-overlap path as a flag, not a
|
||||
standalone trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fnmatch import fnmatch
|
||||
from typing import Any
|
||||
|
||||
# Caps — the roadmap's risk budget. Siblings capped so a 20-child umbrella
|
||||
# doesn't bury the review; globs capped so a glob like ``roboco/**/*.py``.
|
||||
COLLISION_SIBLING_CAP = 10
|
||||
COLLISION_GLOB_CAP = 5
|
||||
|
||||
|
||||
def _globs_overlap(a: list[str], b: list[str]) -> bool:
|
||||
"""True if any path in ``a`` overlaps any in ``b``.
|
||||
|
||||
Mirrors ``SequencingService._globs_overlap`` without the private-method
|
||||
dependency: equality, fnmatch in either direction, or directory-prefix
|
||||
containment (``a/`` contains ``a/b.py``).
|
||||
"""
|
||||
for pa in a:
|
||||
for pb in b:
|
||||
if pa == pb or fnmatch(pa, pb) or fnmatch(pb, pa):
|
||||
return True
|
||||
if pa.startswith(pb.rstrip("/") + "/") or pb.startswith(
|
||||
pa.rstrip("/") + "/"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _glob_matches_file(glob: str, path: str) -> bool:
|
||||
"""True if a declared ``glob`` covers an actual touched ``path`` (fnmatch
|
||||
either direction, or directory containment). Drift = an actual file that
|
||||
no declared glob covers — collision risk the reviewer should flag."""
|
||||
if glob == path or fnmatch(path, glob) or fnmatch(glob, path):
|
||||
return True
|
||||
return path.startswith(glob.rstrip("/") + "/") or glob.startswith(
|
||||
path.rstrip("/") + "/"
|
||||
)
|
||||
|
||||
|
||||
def _glob_overlaps_any(glob: str, others: list[str]) -> bool:
|
||||
"""True if ``glob`` overlaps any glob in ``others`` (the sibling-glob /
|
||||
task-glob intersection that produces the entry's ``overlap`` list)."""
|
||||
return any(_glob_overlaps(glob, other) for other in others)
|
||||
|
||||
|
||||
def _glob_overlaps(a: str, b: str) -> bool:
|
||||
if a == b or fnmatch(a, b) or fnmatch(b, a):
|
||||
return True
|
||||
return a.startswith(b.rstrip("/") + "/") or b.startswith(a.rstrip("/") + "/")
|
||||
|
||||
|
||||
def _surfaced(sib: Any) -> bool:
|
||||
"""A sibling carries a collision surface: a project to collide within
|
||||
and at least one of the three collision signals. Mirrors
|
||||
``sequencing._surfaced_siblings`` so the map shows exactly the siblings
|
||||
the analyzer considered."""
|
||||
return bool(getattr(sib, "project_id", None)) and (
|
||||
bool(getattr(sib, "intends_to_touch", None))
|
||||
or bool(getattr(sib, "adds_migration", False))
|
||||
or bool(getattr(sib, "touches_shared", False))
|
||||
)
|
||||
|
||||
|
||||
def _sort_key(sib: Any) -> tuple[int, int, str]:
|
||||
"""Stable sibling ordering — ``(priority, sequence, id8)`` — so the map's
|
||||
order matches the analyzer's edge order (a re-run only adds entries)."""
|
||||
return (
|
||||
int(getattr(sib, "priority", 2)),
|
||||
int(getattr(sib, "sequence", 0)),
|
||||
str(getattr(sib, "id", ""))[:8],
|
||||
)
|
||||
|
||||
|
||||
def _drift(task_intends: list[str], actual_files: list[str]) -> list[str]:
|
||||
"""Declared-vs-actual drift: actual files the task touched that no declared
|
||||
glob covers — collision risk the reviewer should flag. Capped at
|
||||
``COLLISION_GLOB_CAP``."""
|
||||
return [
|
||||
f
|
||||
for f in actual_files
|
||||
if not any(_glob_matches_file(g, f) for g in task_intends)
|
||||
and f not in task_intends
|
||||
][:COLLISION_GLOB_CAP]
|
||||
|
||||
|
||||
def _sibling_entry(
|
||||
sib: Any,
|
||||
*,
|
||||
task_intends: list[str],
|
||||
actual_files: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""One colliding sibling → its collision-map entry. The caller has already
|
||||
decided the sibling collides (file-overlap or both-add-migration); this
|
||||
renders it: status/branch/PR/sequence, the sibling's declared globs, the
|
||||
overlap globs, and — when the caller handed real touched files — the
|
||||
task's declared-vs-actual drift."""
|
||||
sib_intends = list(getattr(sib, "intends_to_touch", None) or [])
|
||||
overlap = [g for g in sib_intends if _glob_overlaps_any(g, task_intends)][
|
||||
:COLLISION_GLOB_CAP
|
||||
]
|
||||
entry: dict[str, Any] = {
|
||||
"id": str(getattr(sib, "id", ""))[:8],
|
||||
"title": getattr(sib, "title", None),
|
||||
"status": str(getattr(sib, "status", "")),
|
||||
"branch_name": getattr(sib, "branch_name", None),
|
||||
"pr_number": getattr(sib, "pr_number", None),
|
||||
"sequence": getattr(sib, "sequence", None),
|
||||
"intends_to_touch": sib_intends[:COLLISION_GLOB_CAP],
|
||||
"adds_migration": bool(getattr(sib, "adds_migration", False)),
|
||||
"touches_shared": bool(getattr(sib, "touches_shared", False)),
|
||||
"overlap": overlap,
|
||||
}
|
||||
# Only when the caller handed real touched files (QA / gate; not the PM
|
||||
# planning path, which has no work yet).
|
||||
if actual_files:
|
||||
undeclared = _drift(task_intends, actual_files)
|
||||
if undeclared:
|
||||
entry["undeclared"] = undeclared
|
||||
return entry
|
||||
|
||||
|
||||
def _candidates(task: Any, siblings: list[Any]) -> list[Any]:
|
||||
"""Surfaced, not-self, same-project siblings — the pool the analyzer
|
||||
would consider for an edge against ``task``."""
|
||||
task_project = getattr(task, "project_id", None)
|
||||
task_id = getattr(task, "id", None)
|
||||
return [
|
||||
s
|
||||
for s in siblings
|
||||
if _surfaced(s)
|
||||
and getattr(s, "id", None) != task_id
|
||||
and getattr(s, "project_id", None) == task_project
|
||||
]
|
||||
|
||||
|
||||
def _collides_with(task_intends: list[str], task_migrates: bool, sib: Any) -> bool:
|
||||
"""File-overlap OR both-add-migration — the analyzer's edge predicate.
|
||||
The Alembic-head collision (both add a migration) needs no file overlap."""
|
||||
if _globs_overlap(task_intends, list(getattr(sib, "intends_to_touch", None) or [])):
|
||||
return True
|
||||
return task_migrates and bool(getattr(sib, "adds_migration", False))
|
||||
|
||||
|
||||
def build_collision_context(
|
||||
*,
|
||||
task: Any,
|
||||
siblings: list[Any],
|
||||
actual_files: list[str] | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""The collision map for ``task`` against its surfaced siblings.
|
||||
|
||||
Returns ``None`` (block omitted, zero token cost) when the task has no
|
||||
parent (a root — no siblings to collide with) or no surfaced siblings.
|
||||
Otherwise one entry per colliding sibling, capped at
|
||||
``COLLISION_SIBLING_CAP``, ordered by the analyzer's stable key.
|
||||
|
||||
``actual_files`` is the task's real touched-file list (from git, where
|
||||
available). When present, each entry carries ``undeclared`` — actual
|
||||
files not matched by the task's declared globs. The PM planning path
|
||||
passes ``None`` (no work yet), so drift is omitted there by design.
|
||||
"""
|
||||
parent_id = getattr(task, "parent_task_id", None)
|
||||
if not parent_id:
|
||||
return None
|
||||
task_intends = list(getattr(task, "intends_to_touch", None) or [])
|
||||
task_migrates = bool(getattr(task, "adds_migration", False))
|
||||
actual = list(actual_files) if actual_files else None
|
||||
|
||||
candidates = _candidates(task, siblings)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for sib in sorted(candidates, key=_sort_key):
|
||||
if not _collides_with(task_intends, task_migrates, sib):
|
||||
continue
|
||||
entries.append(
|
||||
_sibling_entry(sib, task_intends=task_intends, actual_files=actual)
|
||||
)
|
||||
if len(entries) >= COLLISION_SIBLING_CAP:
|
||||
break
|
||||
return entries or None
|
||||
@@ -29,6 +29,7 @@ from roboco.foundation.policy.content import (
|
||||
validate_findings,
|
||||
)
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
from roboco.services.gateway.choreographer.collision import build_collision_context
|
||||
from roboco.services.gateway.envelope import Envelope
|
||||
from roboco.services.gateway.evidence_builder import render_findings
|
||||
from roboco.services.gateway.merge_chain import resolve_parent_branch
|
||||
@@ -1182,6 +1183,47 @@ class PRGateMixin(_Base):
|
||||
logger.warning("gate_diff_parent_skip", task_id=str(t.id), error=str(exc))
|
||||
return None
|
||||
|
||||
async def _gate_changed_files(self, t: Any, gate_parent: str | None) -> list[str]:
|
||||
"""The assembled PR's real touched files, same base as the diff — so
|
||||
the collision map's declared-vs-actual drift is accurate. Best-effort:
|
||||
a fetch failure yields ``[]`` and drift is simply omitted."""
|
||||
try:
|
||||
return list(
|
||||
await self.git.list_changed_files(
|
||||
branch_name=t.branch_name, preferred_parent=gate_parent
|
||||
)
|
||||
)
|
||||
except Exception as exc: # best-effort; drift omits on failure
|
||||
logger.warning(
|
||||
"gate_review_files_changed_skip",
|
||||
task_id=str(t.id),
|
||||
error=str(exc),
|
||||
)
|
||||
return []
|
||||
|
||||
async def _gate_collision_evidence(
|
||||
self, t: Any, files_changed: list[str]
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""The collision map for an assembled task — surfaced siblings that
|
||||
would collide with it, with declared-vs-actual drift (the gate has
|
||||
the real touched files in hand). Best-effort — mirrors the
|
||||
``parent_context`` block; a failure omits the block, never breaks the
|
||||
gate."""
|
||||
if not t.parent_task_id:
|
||||
return None
|
||||
try:
|
||||
siblings = await self.task.get_subtasks(t.parent_task_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"gate_review_collision_context_skip",
|
||||
task_id=str(t.id),
|
||||
error=str(exc),
|
||||
)
|
||||
return None
|
||||
return build_collision_context(
|
||||
task=t, siblings=siblings, actual_files=files_changed or None
|
||||
)
|
||||
|
||||
async def _build_gate_review_evidence(self, t: Any) -> dict[str, Any]:
|
||||
"""Inline evidence for claim_gate_review: the assembled diff +
|
||||
criteria + the task's OPEN findings (so they aren't crowded out by
|
||||
@@ -1189,11 +1231,14 @@ class PRGateMixin(_Base):
|
||||
newest round first) so the reviewer verifies prior rounds
|
||||
item-by-item — parity with QA's ``_build_qa_claim_evidence``."""
|
||||
diff = ""
|
||||
files_changed: list[str] = []
|
||||
if t.branch_name:
|
||||
gate_parent = await self._gate_diff_parent(t)
|
||||
diff = await self.git.diff(
|
||||
branch_name=t.branch_name,
|
||||
preferred_parent=await self._gate_diff_parent(t),
|
||||
preferred_parent=gate_parent,
|
||||
)
|
||||
files_changed = await self._gate_changed_files(t, gate_parent)
|
||||
open_findings = await findings_lib.open_findings_for_task(
|
||||
self.task.session, t.id
|
||||
)
|
||||
@@ -1225,4 +1270,7 @@ class PRGateMixin(_Base):
|
||||
evidence["description"] = description
|
||||
if parent_context:
|
||||
evidence["parent_context"] = parent_context
|
||||
collision = await self._gate_collision_evidence(t, files_changed)
|
||||
if collision:
|
||||
evidence["collision_context"] = collision
|
||||
return evidence
|
||||
|
||||
@@ -52,6 +52,7 @@ from roboco.foundation.policy.content import (
|
||||
from roboco.services.content_notes import apply_structured_note
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
from roboco.services.gateway.choreographer._protocol import actor_context_fields
|
||||
from roboco.services.gateway.choreographer.collision import build_collision_context
|
||||
from roboco.services.gateway.envelope import Envelope
|
||||
from roboco.services.gateway.evidence_builder import build_evidence_for_task
|
||||
|
||||
@@ -238,6 +239,21 @@ class QAMixin(_Base):
|
||||
prior_findings = await findings_lib.full_ledger_for_task(
|
||||
self.task.session, t.id
|
||||
)
|
||||
# The collision map: surfaced siblings (same parent) that would
|
||||
# collide with this task, with the declared-vs-actual drift (QA has
|
||||
# the real touched files in hand). Best-effort — a fetch failure omits
|
||||
# the block rather than breaking claim_review.
|
||||
collision_context: list[dict[str, Any]] | None = None
|
||||
try:
|
||||
if t.parent_task_id:
|
||||
siblings = await self.task.get_subtasks(t.parent_task_id)
|
||||
collision_context = build_collision_context(
|
||||
task=t, siblings=siblings, actual_files=files_changed
|
||||
)
|
||||
except Exception as exc: # best-effort enrichment, never breaks the verb
|
||||
logger.warning(
|
||||
"qa_collision_context_skip", task_id=str(t.id), error=str(exc)
|
||||
)
|
||||
return build_evidence_for_task(
|
||||
t,
|
||||
journal_highlights=journal_highlights,
|
||||
@@ -247,6 +263,7 @@ class QAMixin(_Base):
|
||||
revision_findings=open_findings,
|
||||
prior_findings=prior_findings,
|
||||
parent_context=parent_context,
|
||||
collision_context=collision_context,
|
||||
)
|
||||
|
||||
async def _verify_qa_owner(
|
||||
|
||||
@@ -25,6 +25,7 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
|
||||
"prior_findings",
|
||||
"parent_context",
|
||||
"description",
|
||||
"collision_context",
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +59,11 @@ class EvidencePayload:
|
||||
# newest round first, so the round-N+1 reviewer verifies prior rounds
|
||||
# item-by-item instead of seeing only what is still open.
|
||||
prior_findings: list[dict[str, Any]] = field(default_factory=list)
|
||||
# The collision map: surfaced siblings (same parent) that would collide
|
||||
# with this task — file-overlap globs + migration-chain siblings + the
|
||||
# declared-vs-actual drift. Empty for a root or a task with no colliding
|
||||
# siblings; the block is omitted when empty (zero token cost).
|
||||
collision_context: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
@@ -160,6 +166,7 @@ def build_evidence_for_task(
|
||||
revision_findings: list[Any] | None = None,
|
||||
prior_findings: list[Any] | None = None,
|
||||
parent_context: list[dict[str, Any]] | None = None,
|
||||
collision_context: list[dict[str, Any]] | None = None,
|
||||
) -> EvidencePayload:
|
||||
"""Compose an EvidencePayload from a Task model + supplemental data.
|
||||
|
||||
@@ -167,7 +174,10 @@ def build_evidence_for_task(
|
||||
caller fetches; this module stays DB-free) and render them via
|
||||
``render_findings``. ``parent_context`` is the upstream ``description``
|
||||
chain (parent → root) the caller fetches via EvidenceRepo so the dev
|
||||
reads the intake's original analysis verbatim.
|
||||
reads the intake's original analysis verbatim. ``collision_context`` is
|
||||
the prebuilt collision-map block (the caller fetches siblings + actual
|
||||
files and runs the pure ``build_collision_context``); passed through
|
||||
verbatim so this module stays DB-free.
|
||||
"""
|
||||
return EvidencePayload(
|
||||
pr_number=task.pr_number,
|
||||
@@ -183,6 +193,7 @@ def build_evidence_for_task(
|
||||
convention_findings=list(convention_findings or []),
|
||||
revision_findings=render_findings(revision_findings),
|
||||
prior_findings=render_findings(prior_findings),
|
||||
collision_context=list(collision_context or []),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Also implements the ACK system for tracking acknowledgments.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
||||
@@ -874,6 +875,43 @@ class NotificationDeliveryService(BaseService):
|
||||
escalator_slug=escalator.slug,
|
||||
)
|
||||
|
||||
async def _notify_telegram(self, *, task_id: UUID, subject: str) -> None:
|
||||
"""Best-effort Telegram DM to the CEO alongside an in-app notification.
|
||||
|
||||
Degrades to a no-op unless ``telegram_enabled`` is armed and credentials
|
||||
are stored. Never raises into the caller — a network/credentials failure
|
||||
only logs. The message carries a panel deep-link when ``panel_base_url``
|
||||
is set.
|
||||
"""
|
||||
from roboco.config import settings
|
||||
|
||||
if not settings.telegram_enabled:
|
||||
return
|
||||
from roboco.services.telegram_client import build_telegram_client
|
||||
from roboco.services.telegram_credentials import (
|
||||
get_telegram_credentials_service,
|
||||
)
|
||||
|
||||
try:
|
||||
creds = await get_telegram_credentials_service(self.session).get_decrypted()
|
||||
client = build_telegram_client(
|
||||
creds, timeout=settings.telegram_timeout_seconds
|
||||
)
|
||||
text = subject
|
||||
if settings.panel_base_url:
|
||||
link = f"{settings.panel_base_url.rstrip('/')}/tasks/{str(task_id)[:8]}"
|
||||
text = f"{subject}\n{link}"
|
||||
result = await client.send_message(text)
|
||||
if not result.sent:
|
||||
_log.warning("telegram_notify_skip", detail=result.detail)
|
||||
except Exception as exc: # best-effort — never block the producer
|
||||
_log.warning("telegram_notify_failed", error=str(exc))
|
||||
finally:
|
||||
close = getattr(locals().get("client"), "close", None)
|
||||
if close is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await close()
|
||||
|
||||
async def notify_ceo_of_escalation(
|
||||
self,
|
||||
*,
|
||||
@@ -904,6 +942,7 @@ class NotificationDeliveryService(BaseService):
|
||||
requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL],
|
||||
)
|
||||
await self._persist_and_deliver(notification)
|
||||
await self._notify_telegram(task_id=task_id, subject=notification.subject)
|
||||
|
||||
async def notify_ceo_of_completion(self, *, task: TaskTable, task_id: UUID) -> None:
|
||||
"""CEO-facing completion notification with the granular effort breakdown.
|
||||
@@ -933,6 +972,7 @@ class NotificationDeliveryService(BaseService):
|
||||
requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.ALERT],
|
||||
)
|
||||
await self._persist_and_deliver(notification)
|
||||
await self._notify_telegram(task_id=task_id, subject=notification.subject)
|
||||
|
||||
async def notify_auditor_of_rework(
|
||||
self,
|
||||
|
||||
+17
-11
@@ -23,6 +23,22 @@ _INACTIVE_STATUSES = (
|
||||
)
|
||||
|
||||
|
||||
def _project_to_products_map(
|
||||
products: list[ProductTable],
|
||||
) -> dict[UUID, list[UUID]]:
|
||||
"""Distinct (project_id, product_id) pairs — dedup the monorepo case."""
|
||||
proj_to_products: dict[UUID, list[UUID]] = {}
|
||||
for product in products:
|
||||
seen: set[UUID] = set()
|
||||
for cell in product.cells:
|
||||
pid = typing_cast("UUID", cell.project_id)
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
proj_to_products.setdefault(pid, []).append(typing_cast("UUID", product.id))
|
||||
return proj_to_products
|
||||
|
||||
|
||||
class ProductService(BaseService):
|
||||
service_name: ClassVar[str] = "product"
|
||||
|
||||
@@ -114,17 +130,7 @@ class ProductService(BaseService):
|
||||
per product. Returns {product_id: {done, active, blocked}}.
|
||||
"""
|
||||
# distinct (product_id, project_id) pairs — dedup the monorepo case.
|
||||
proj_to_products: dict[UUID, list[UUID]] = {}
|
||||
for product in products:
|
||||
seen: set[UUID] = set()
|
||||
for cell in product.cells:
|
||||
pid = typing_cast("UUID", cell.project_id)
|
||||
if pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
proj_to_products.setdefault(pid, []).append(
|
||||
typing_cast("UUID", product.id)
|
||||
)
|
||||
proj_to_products = _project_to_products_map(products)
|
||||
if not proj_to_products:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
|
||||
("vault_intake_enabled", "Vault intake watcher (notes -> held drafts)"),
|
||||
("vault_report_enabled", "Vault weekly org-report note"),
|
||||
("vault_kb_enabled", "Vault KB ingest (CEO notes -> RAG)"),
|
||||
("telegram_enabled", "Telegram notifications bridge (CEO DMs)"),
|
||||
)
|
||||
_FEATURE_FLAG_KEYS = tuple(key for key, _ in FEATURE_FLAGS)
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Telegram Bot API client — server-side only, agents never touch it.
|
||||
|
||||
Thin httpx wrapper for the one operation the V1 bridge needs: send a message
|
||||
to a chat. Mirrors ``services/x_client.py``'s ``NullXClient`` shape — a
|
||||
``NullTelegramClient`` is returned when credentials are unset, so the
|
||||
notification fan-out degrades gracefully (no-op, no exception) exactly like an
|
||||
unconfigured X client — never raises into the caller, never makes a network
|
||||
call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.services.telegram_credentials import TelegramCredentialsData
|
||||
|
||||
_API_BASE = "https://api.telegram.org"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramSendResult:
|
||||
"""Outcome of a ``send_message`` call."""
|
||||
|
||||
sent: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class TelegramClient(ABC):
|
||||
"""Abstract Telegram API surface. ``NullTelegramClient`` is the
|
||||
graceful-degradation stub."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def configured(self) -> bool: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(self, text: str) -> TelegramSendResult: ...
|
||||
|
||||
|
||||
class NullTelegramClient(TelegramClient):
|
||||
"""No credentials configured — every call is a no-op, never raises."""
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return False
|
||||
|
||||
async def send_message(self, text: str) -> TelegramSendResult:
|
||||
_ = text
|
||||
return TelegramSendResult(sent=False, detail="no credentials configured")
|
||||
|
||||
|
||||
class LiveTelegramClient(TelegramClient):
|
||||
"""Real Bot API ``sendMessage`` call."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
creds: TelegramCredentialsData,
|
||||
*,
|
||||
timeout: float,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._creds = creds
|
||||
self._timeout = timeout
|
||||
self._client = client
|
||||
self._owns_client = client is None
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return True
|
||||
|
||||
async def _http(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(timeout=self._timeout)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_client and self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def send_message(self, text: str) -> TelegramSendResult:
|
||||
url = f"{_API_BASE}/bot{self._creds.bot_token}/sendMessage"
|
||||
client = await self._http()
|
||||
try:
|
||||
resp = await client.post(
|
||||
url,
|
||||
json={"chat_id": self._creds.chat_id, "text": text},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return TelegramSendResult(sent=False, detail=f"network error: {exc}")
|
||||
if not resp.is_success:
|
||||
return TelegramSendResult(
|
||||
sent=False,
|
||||
detail=f"HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
)
|
||||
return TelegramSendResult(sent=True)
|
||||
|
||||
|
||||
def build_telegram_client(
|
||||
creds: TelegramCredentialsData | None,
|
||||
*,
|
||||
timeout: float,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
) -> TelegramClient:
|
||||
"""Construct the client — ``NullTelegramClient`` when credentials are unset."""
|
||||
if creds is None:
|
||||
return NullTelegramClient()
|
||||
return LiveTelegramClient(creds, timeout=timeout, client=client)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Telegram bot credentials — a Fernet-encrypted singleton row.
|
||||
|
||||
Mirrors ``services/x_credentials.py``: the bot token + chat id are indivisible
|
||||
(a token alone can't target a DM), so this service treats them as
|
||||
all-or-nothing — set both together, or clear both together. Decryption is
|
||||
server-side only; ``telegram_client`` is the sole reader of ``get_decrypted``;
|
||||
the API never returns plaintext (``has_credentials`` boolean pattern).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from roboco.db.tables import TelegramCredentialsTable
|
||||
from roboco.services.base import BaseService
|
||||
from roboco.utils.crypto import EncryptionError, decrypt_token, encrypt_token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class TelegramCredentialsValidationError(ValueError):
|
||||
"""Raised when a partial (not all-or-nothing) credential set is given."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TelegramCredentialsData:
|
||||
"""The decrypted bot token + chat id, server-side only."""
|
||||
|
||||
bot_token: str
|
||||
chat_id: str
|
||||
|
||||
|
||||
class TelegramCredentialsService(BaseService):
|
||||
"""CRUD for the single ``telegram_credentials`` row."""
|
||||
|
||||
service_name: ClassVar[str] = "telegram_credentials"
|
||||
|
||||
async def _get_row(self) -> TelegramCredentialsTable | None:
|
||||
result = await self.session.execute(select(TelegramCredentialsTable).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def has_credentials(self) -> bool:
|
||||
"""True iff both the bot token and chat id are stored."""
|
||||
row = await self._get_row()
|
||||
if row is None:
|
||||
return False
|
||||
return bool(row.bot_token_encrypted and row.chat_id_encrypted)
|
||||
|
||||
async def set_credentials(self, *, bot_token: str, chat_id: str) -> bool:
|
||||
"""Set (encrypt) or clear the bot token + chat id together.
|
||||
|
||||
Both empty -> clears the row. Both non-empty -> encrypts and upserts.
|
||||
A mixed set (one empty, one not) raises
|
||||
:class:`TelegramCredentialsValidationError` — a partial set can't send.
|
||||
Returns the resulting ``has_credentials``.
|
||||
"""
|
||||
values = (bot_token, chat_id)
|
||||
non_empty = sum(1 for v in values if v)
|
||||
if non_empty not in (0, len(values)):
|
||||
raise TelegramCredentialsValidationError(
|
||||
"bot token and chat id must be set or cleared together"
|
||||
)
|
||||
|
||||
row = await self._get_row()
|
||||
if non_empty == 0:
|
||||
if row is not None:
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
self.log.info("Telegram credentials cleared")
|
||||
return False
|
||||
|
||||
try:
|
||||
encrypted = (encrypt_token(bot_token), encrypt_token(chat_id))
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to encrypt Telegram credentials", error=str(e))
|
||||
raise
|
||||
|
||||
if row is None:
|
||||
row = TelegramCredentialsTable()
|
||||
self.session.add(row)
|
||||
row.bot_token_encrypted, row.chat_id_encrypted = encrypted
|
||||
await self.session.flush()
|
||||
self.log.info("Telegram credentials set")
|
||||
return True
|
||||
|
||||
async def get_decrypted(self) -> TelegramCredentialsData | None:
|
||||
"""The decrypted bot token + chat id, or None when unset. Server-side only."""
|
||||
row = await self._get_row()
|
||||
if row is None or not (row.bot_token_encrypted and row.chat_id_encrypted):
|
||||
return None
|
||||
try:
|
||||
return TelegramCredentialsData(
|
||||
bot_token=decrypt_token(row.bot_token_encrypted),
|
||||
chat_id=decrypt_token(row.chat_id_encrypted),
|
||||
)
|
||||
except EncryptionError as e:
|
||||
self.log.error("Failed to decrypt Telegram credentials", error=str(e))
|
||||
raise
|
||||
|
||||
|
||||
def get_telegram_credentials_service(
|
||||
session: AsyncSession,
|
||||
) -> TelegramCredentialsService:
|
||||
"""Construct a TelegramCredentialsService bound to ``session``."""
|
||||
return TelegramCredentialsService(session)
|
||||
@@ -0,0 +1,180 @@
|
||||
"""GET /api/tasks/{id}/collision-map — the reviewer/PM collision map route.
|
||||
|
||||
Read-only feed for the panel's Collision tab: the task's own declared
|
||||
surface plus the surfaced siblings (same parent) that would collide with
|
||||
it. Mirrors test_task_findings_route.py's fixture shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.tasks import router as tasks_router
|
||||
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.models.permissions import AgentContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def collision_client(db_session: AsyncSession) -> AsyncIterator[dict]:
|
||||
pm = AgentTable(
|
||||
id=uuid4(),
|
||||
name="PM",
|
||||
slug=f"pm-{uuid4().hex[:8]}",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(pm)
|
||||
await db_session.flush()
|
||||
project = ProjectTable(
|
||||
id=uuid4(),
|
||||
name="CM-Proj",
|
||||
slug=f"cm-proj-{uuid4().hex[:6]}",
|
||||
git_url="https://example.com/cm.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=pm.id,
|
||||
)
|
||||
db_session.add(project)
|
||||
await db_session.flush()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(tasks_router, prefix="/api/tasks")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
async def _override_agent() -> AgentContext:
|
||||
return AgentContext(
|
||||
agent_id=cast("UUID", pm.id), role=AgentRole.MAIN_PM, team=None
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_agent_context] = _override_agent
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield {"client": client, "agent": pm, "project": project, "db": db_session}
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _task(setup: dict, **kw: Any) -> TaskTable:
|
||||
task = TaskTable(
|
||||
id=uuid4(),
|
||||
title=kw.pop("title", "t"),
|
||||
description=kw.pop("description", "d"),
|
||||
acceptance_criteria=["ac"],
|
||||
status=kw.pop("status", TaskStatus.IN_PROGRESS),
|
||||
priority=kw.pop("priority", 2),
|
||||
sequence=kw.pop("sequence", 0),
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
project_id=setup["project"].id,
|
||||
created_by=setup["agent"].id,
|
||||
team=Team.BACKEND,
|
||||
**kw,
|
||||
)
|
||||
setup["db"].add(task)
|
||||
return task
|
||||
|
||||
|
||||
_HDR = {"X-Agent-ID": "ignored", "X-Agent-Role": "main_pm"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_map_404_for_missing_task(collision_client: dict) -> None:
|
||||
client = collision_client["client"]
|
||||
response = await client.get(f"/api/tasks/{uuid4()}/collision-map", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_map_empty_for_rootless_task(collision_client: dict) -> None:
|
||||
client = collision_client["client"]
|
||||
task = _task(collision_client, intends_to_touch=["roboco/services/git.py"])
|
||||
await collision_client["db"].flush()
|
||||
response = await client.get(f"/api/tasks/{task.id}/collision-map", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
body = response.json()
|
||||
assert body["parent_task_id"] is None
|
||||
assert body["intends_to_touch"] == ["roboco/services/git.py"]
|
||||
assert body["siblings"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_map_shows_overlapping_sibling(collision_client: dict) -> None:
|
||||
client = collision_client["client"]
|
||||
parent = _task(collision_client, title="parent", status=TaskStatus.PENDING)
|
||||
await collision_client["db"].flush()
|
||||
under = _task(
|
||||
collision_client,
|
||||
parent_task_id=parent.id,
|
||||
title="under review",
|
||||
intends_to_touch=["roboco/services/git.py"],
|
||||
sequence=0,
|
||||
)
|
||||
_task(
|
||||
collision_client,
|
||||
parent_task_id=parent.id,
|
||||
title="colliding sibling",
|
||||
intends_to_touch=["roboco/services/git.py", "roboco/services/x.py"],
|
||||
sequence=1,
|
||||
)
|
||||
await collision_client["db"].flush()
|
||||
|
||||
response = await client.get(f"/api/tasks/{under.id}/collision-map", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
body = response.json()
|
||||
assert body["parent_task_id"] == str(parent.id)
|
||||
assert len(body["siblings"]) == 1
|
||||
sib_entry = body["siblings"][0]
|
||||
assert sib_entry["title"] == "colliding sibling"
|
||||
assert "roboco/services/git.py" in sib_entry["overlap"]
|
||||
# panel path carries no actual files → drift is empty (the QA/gate
|
||||
# evidence envelopes populate it; the panel schema defaults to []).
|
||||
assert sib_entry["undeclared"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collision_map_omits_non_overlapping_sibling(
|
||||
collision_client: dict,
|
||||
) -> None:
|
||||
client = collision_client["client"]
|
||||
parent = _task(collision_client, title="parent", status=TaskStatus.PENDING)
|
||||
await collision_client["db"].flush()
|
||||
under = _task(
|
||||
collision_client,
|
||||
parent_task_id=parent.id,
|
||||
title="under review",
|
||||
intends_to_touch=["roboco/services/git.py"],
|
||||
)
|
||||
_task(
|
||||
collision_client,
|
||||
parent_task_id=parent.id,
|
||||
title="parallel sibling",
|
||||
intends_to_touch=["roboco/services/other.py"],
|
||||
)
|
||||
await collision_client["db"].flush()
|
||||
|
||||
response = await client.get(f"/api/tasks/{under.id}/collision-map", headers=_HDR)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
body = response.json()
|
||||
assert body["siblings"] == [] # no overlap, no shared migration
|
||||
@@ -0,0 +1,93 @@
|
||||
"""TelegramCredentialsService coverage — encrypt/roundtrip, all-or-nothing set/clear.
|
||||
|
||||
Drives a real ``db_session`` via the project's Postgres-backed conftest. The
|
||||
service never returns plaintext to a caller other than ``get_decrypted`` (the
|
||||
server-side-only reader) — the API layer only ever sees ``has_credentials``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db.tables import TelegramCredentialsTable
|
||||
from roboco.services.telegram_credentials import (
|
||||
TelegramCredentialsService,
|
||||
TelegramCredentialsValidationError,
|
||||
get_telegram_credentials_service,
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_CREDS = {"bot_token": "123456:ABC-bot-token", "chat_id": "987654321"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def svc(
|
||||
db_session: AsyncSession,
|
||||
) -> AsyncIterator[TelegramCredentialsService]:
|
||||
yield get_telegram_credentials_service(db_session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unset_has_no_credentials(svc: TelegramCredentialsService) -> None:
|
||||
assert await svc.has_credentials() is False
|
||||
assert await svc.get_decrypted() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_both_encrypts_and_roundtrips(
|
||||
svc: TelegramCredentialsService,
|
||||
) -> None:
|
||||
has_creds = await svc.set_credentials(**_CREDS)
|
||||
assert has_creds is True
|
||||
assert await svc.has_credentials() is True
|
||||
|
||||
decrypted = await svc.get_decrypted()
|
||||
assert decrypted is not None
|
||||
assert decrypted.bot_token == _CREDS["bot_token"]
|
||||
assert decrypted.chat_id == _CREDS["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stored_row_never_holds_plaintext(
|
||||
svc: TelegramCredentialsService, db_session: AsyncSession
|
||||
) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
result = await db_session.execute(select(TelegramCredentialsTable).limit(1))
|
||||
row = result.scalar_one_or_none()
|
||||
assert row is not None
|
||||
assert row.bot_token_encrypted != _CREDS["bot_token"]
|
||||
assert row.chat_id_encrypted != _CREDS["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clearing_both_removes_row(svc: TelegramCredentialsService) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
has_creds = await svc.set_credentials(bot_token="", chat_id="")
|
||||
assert has_creds is False
|
||||
assert await svc.has_credentials() is False
|
||||
assert await svc.get_decrypted() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_set_is_rejected(svc: TelegramCredentialsService) -> None:
|
||||
with pytest.raises(TelegramCredentialsValidationError):
|
||||
await svc.set_credentials(bot_token="only-one", chat_id="")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rotate_overwrites_previous_values(
|
||||
svc: TelegramCredentialsService,
|
||||
) -> None:
|
||||
await svc.set_credentials(**_CREDS)
|
||||
rotated = {k: f"{v}-rotated" for k, v in _CREDS.items()}
|
||||
await svc.set_credentials(**rotated)
|
||||
decrypted = await svc.get_decrypted()
|
||||
assert decrypted is not None
|
||||
assert decrypted.bot_token == rotated["bot_token"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""W5 collision map: the pure ``build_collision_context`` builder.
|
||||
|
||||
Pins the truth table — no parent → None, no surfaced siblings → None,
|
||||
file-overlap sibling shown, both-migration shown without file overlap,
|
||||
shared-only-without-overlap NOT shown, declared-vs-actual drift computed,
|
||||
and the caps respected. Pure (no DB, no IO): duck-typed task/sibling rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.services.gateway.choreographer.collision import (
|
||||
COLLISION_GLOB_CAP,
|
||||
COLLISION_SIBLING_CAP,
|
||||
build_collision_context,
|
||||
)
|
||||
|
||||
|
||||
def _task(
|
||||
*,
|
||||
parent_task_id: str | None = "p1",
|
||||
project_id: str = "proj",
|
||||
intends_to_touch: list[str] | None = None,
|
||||
adds_migration: bool = False,
|
||||
touches_shared: bool = False,
|
||||
priority: int = 2,
|
||||
sequence: int = 0,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
parent_task_id=parent_task_id,
|
||||
project_id=project_id,
|
||||
intends_to_touch=intends_to_touch,
|
||||
adds_migration=adds_migration,
|
||||
touches_shared=touches_shared,
|
||||
priority=priority,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def _sib(
|
||||
*,
|
||||
project_id: str = "proj",
|
||||
intends_to_touch: list[str] | None = None,
|
||||
adds_migration: bool = False,
|
||||
touches_shared: bool = False,
|
||||
priority: int = 2,
|
||||
sequence: int = 1,
|
||||
status: str = "in_progress",
|
||||
branch_name: str | None = "feature/x",
|
||||
pr_number: int | None = 7,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id=str(uuid4()),
|
||||
project_id=project_id,
|
||||
intends_to_touch=intends_to_touch,
|
||||
adds_migration=adds_migration,
|
||||
touches_shared=touches_shared,
|
||||
priority=priority,
|
||||
sequence=sequence,
|
||||
status=status,
|
||||
branch_name=branch_name,
|
||||
pr_number=pr_number,
|
||||
title="sibling",
|
||||
)
|
||||
|
||||
|
||||
def test_no_parent_returns_none() -> None:
|
||||
task = _task(parent_task_id=None, intends_to_touch=["a.py"])
|
||||
assert build_collision_context(task=task, siblings=[_sib()]) is None
|
||||
|
||||
|
||||
def test_no_surfaced_siblings_returns_none() -> None:
|
||||
task = _task(intends_to_touch=["a.py"])
|
||||
# sibling with no collision surface at all
|
||||
assert (
|
||||
build_collision_context(task=task, siblings=[_sib(intends_to_touch=None)])
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_file_overlap_sibling_shown() -> None:
|
||||
task = _task(intends_to_touch=["roboco/services/git.py"])
|
||||
sib = _sib(intends_to_touch=["roboco/services/git.py", "roboco/services/x.py"])
|
||||
ctx = build_collision_context(task=task, siblings=[sib])
|
||||
assert ctx is not None
|
||||
assert len(ctx) == 1
|
||||
assert "roboco/services/git.py" in ctx[0]["overlap"]
|
||||
|
||||
|
||||
def test_no_overlap_no_migration_not_shown() -> None:
|
||||
task = _task(intends_to_touch=["roboco/a.py"])
|
||||
sib = _sib(intends_to_touch=["roboco/b.py"])
|
||||
assert build_collision_context(task=task, siblings=[sib]) is None
|
||||
|
||||
|
||||
def test_both_migration_shown_without_file_overlap() -> None:
|
||||
task = _task(intends_to_touch=["roboco/a.py"], adds_migration=True)
|
||||
sib = _sib(intends_to_touch=["roboco/b.py"], adds_migration=True)
|
||||
ctx = build_collision_context(task=task, siblings=[sib])
|
||||
assert ctx is not None
|
||||
assert ctx[0]["adds_migration"] is True
|
||||
assert ctx[0]["overlap"] == []
|
||||
|
||||
|
||||
def test_one_migration_only_not_shown() -> None:
|
||||
# migration-chain needs BOTH adders; one alone is parallel.
|
||||
task = _task(intends_to_touch=["roboco/a.py"], adds_migration=True)
|
||||
sib = _sib(intends_to_touch=["roboco/b.py"], adds_migration=False)
|
||||
assert build_collision_context(task=task, siblings=[sib]) is None
|
||||
|
||||
|
||||
def test_shared_only_without_overlap_not_shown() -> None:
|
||||
# touches_shared alone is too broad; it rides the file-overlap path.
|
||||
task = _task(intends_to_touch=["roboco/a.py"], touches_shared=True)
|
||||
sib = _sib(intends_to_touch=["roboco/b.py"])
|
||||
assert build_collision_context(task=task, siblings=[sib]) is None
|
||||
|
||||
|
||||
def test_shared_with_overlap_shown_and_flagged() -> None:
|
||||
task = _task(intends_to_touch=["roboco/a.py"], touches_shared=True)
|
||||
sib = _sib(intends_to_touch=["roboco/a.py"], touches_shared=True)
|
||||
ctx = build_collision_context(task=task, siblings=[sib])
|
||||
assert ctx is not None
|
||||
assert ctx[0]["touches_shared"] is True
|
||||
|
||||
|
||||
def test_cross_project_sibling_not_shown() -> None:
|
||||
# collisions are repo-scoped; a sibling in another repo can't collide.
|
||||
task = _task(intends_to_touch=["a.py"], project_id="proj")
|
||||
sib = _sib(intends_to_touch=["a.py"], project_id="other")
|
||||
assert build_collision_context(task=task, siblings=[sib]) is None
|
||||
|
||||
|
||||
def test_self_excluded_from_siblings() -> None:
|
||||
task = _task(intends_to_touch=["a.py"])
|
||||
self_sib = _sib(intends_to_touch=["a.py"])
|
||||
self_sib.id = task.id
|
||||
assert build_collision_context(task=task, siblings=[self_sib]) is None
|
||||
|
||||
|
||||
def test_drift_undeclared_computed() -> None:
|
||||
task = _task(intends_to_touch=["roboco/a.py"])
|
||||
sib = _sib(intends_to_touch=["roboco/a.py"])
|
||||
ctx = build_collision_context(
|
||||
task=task,
|
||||
siblings=[sib],
|
||||
actual_files=["roboco/a.py", "roboco/secret.py"],
|
||||
)
|
||||
assert ctx is not None
|
||||
assert ctx[0]["undeclared"] == ["roboco/secret.py"]
|
||||
|
||||
|
||||
def test_drift_omitted_without_actual_files() -> None:
|
||||
task = _task(intends_to_touch=["roboco/a.py"])
|
||||
sib = _sib(intends_to_touch=["roboco/a.py"])
|
||||
ctx = build_collision_context(task=task, siblings=[sib])
|
||||
assert ctx is not None
|
||||
assert "undeclared" not in ctx[0]
|
||||
|
||||
|
||||
def test_sibling_cap() -> None:
|
||||
task = _task(intends_to_touch=["a.py"])
|
||||
sibs = [
|
||||
_sib(intends_to_touch=["a.py"], sequence=i)
|
||||
for i in range(COLLISION_SIBLING_CAP + 5)
|
||||
]
|
||||
ctx = build_collision_context(task=task, siblings=sibs)
|
||||
assert ctx is not None
|
||||
assert len(ctx) == COLLISION_SIBLING_CAP
|
||||
|
||||
|
||||
def test_glob_cap() -> None:
|
||||
task = _task(intends_to_touch=[f"f{i}.py" for i in range(COLLISION_GLOB_CAP + 5)])
|
||||
sib = _sib(intends_to_touch=[f"f{i}.py" for i in range(COLLISION_GLOB_CAP + 5)])
|
||||
ctx = build_collision_context(task=task, siblings=[sib])
|
||||
assert ctx is not None
|
||||
assert len(ctx[0]["overlap"]) <= COLLISION_GLOB_CAP
|
||||
assert len(ctx[0]["intends_to_touch"]) <= COLLISION_GLOB_CAP
|
||||
|
||||
|
||||
def test_sort_key_orders_by_priority_then_sequence() -> None:
|
||||
task = _task(intends_to_touch=["a.py"])
|
||||
low_prio = _sib(intends_to_touch=["a.py"], priority=3, sequence=0)
|
||||
high_prio = _sib(intends_to_touch=["a.py"], priority=1, sequence=5)
|
||||
mid = _sib(intends_to_touch=["a.py"], priority=2, sequence=1)
|
||||
ctx = build_collision_context(task=task, siblings=[low_prio, high_prio, mid])
|
||||
assert ctx is not None
|
||||
assert [e["sequence"] for e in ctx] == [5, 1, 0]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
pytest.main([__file__, "-q"])
|
||||
@@ -7,12 +7,16 @@ dedup of the same project across a product's cells).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from roboco.services.product import ProductService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from roboco.db.tables import ProductTable
|
||||
|
||||
_PRODUCT_A = UUID("11111111-1111-1111-1111-111111111111")
|
||||
_PRODUCT_B = UUID("22222222-2222-2222-2222-222222222222")
|
||||
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
@@ -25,11 +29,11 @@ def _cell(project_id: UUID) -> MagicMock:
|
||||
return cell
|
||||
|
||||
|
||||
def _product(pid: UUID, project_ids: list[UUID]) -> MagicMock:
|
||||
def _product(pid: UUID, project_ids: list[UUID]) -> ProductTable:
|
||||
p = MagicMock()
|
||||
p.id = pid
|
||||
p.cells = [_cell(pid_proj) for pid_proj in project_ids]
|
||||
return p
|
||||
return cast("ProductTable", p)
|
||||
|
||||
|
||||
def _result_fetchall(rows: list[MagicMock]) -> MagicMock:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""TelegramClient coverage: NullTelegramClient no-op, LiveTelegramClient calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from roboco.services.telegram_client import (
|
||||
LiveTelegramClient,
|
||||
NullTelegramClient,
|
||||
build_telegram_client,
|
||||
)
|
||||
from roboco.services.telegram_credentials import TelegramCredentialsData
|
||||
|
||||
_CREDS = TelegramCredentialsData(bot_token="123456:ABC", chat_id="987654321")
|
||||
|
||||
|
||||
def test_null_client_is_unconfigured() -> None:
|
||||
client = build_telegram_client(None, timeout=5.0)
|
||||
assert isinstance(client, NullTelegramClient)
|
||||
assert client.configured is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_client_send_message_is_a_noop() -> None:
|
||||
client: NullTelegramClient = NullTelegramClient()
|
||||
result = await client.send_message("hello")
|
||||
assert result.sent is False
|
||||
assert result.detail # non-empty reason
|
||||
|
||||
|
||||
def test_build_telegram_client_with_creds_returns_live_client() -> None:
|
||||
client = build_telegram_client(_CREDS, timeout=5.0)
|
||||
assert isinstance(client, LiveTelegramClient)
|
||||
assert client.configured is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_send_message_success() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/bot123456:ABC/sendMessage"
|
||||
body = json.loads(request.content.decode())
|
||||
assert body == {"chat_id": _CREDS.chat_id, "text": "hi"}
|
||||
return httpx.Response(200, json={"ok": True, "result": {"message_id": 1}})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
result = await client.send_message("hi")
|
||||
assert result.sent is True
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_client_send_message_http_error_is_graceful() -> None:
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, text="unauthorized")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
http_client = httpx.AsyncClient(transport=transport)
|
||||
client = LiveTelegramClient(_CREDS, timeout=5.0, client=http_client)
|
||||
result = await client.send_message("hi")
|
||||
assert result.sent is False
|
||||
assert "401" in result.detail
|
||||
await client.close()
|
||||
@@ -13,6 +13,7 @@ verify the arithmetic / logic of each analytics method:
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
@@ -349,7 +350,7 @@ class TestGetTimeSeries:
|
||||
)
|
||||
svc = _service_with_execute(_result_fetchall([row]))
|
||||
await svc.get_time_series("7d", agent_slug="be-dev-1")
|
||||
stmt = svc.session.execute.call_args_list[0][0][0]
|
||||
stmt = cast("MagicMock", svc.session.execute).call_args_list[0][0][0]
|
||||
sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "be-dev-1" in sql
|
||||
|
||||
@@ -366,7 +367,7 @@ class TestGetTimeSeries:
|
||||
)
|
||||
svc = _service_with_execute(_result_fetchall([row]))
|
||||
await svc.get_time_series("7d")
|
||||
stmt = svc.session.execute.call_args_list[0][0][0]
|
||||
stmt = cast("MagicMock", svc.session.execute).call_args_list[0][0][0]
|
||||
sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
|
||||
assert "agent_slug" not in sql
|
||||
|
||||
|
||||
Reference in New Issue
Block a user