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:
Renzo F
2026-07-15 05:45:57 +02:00
committed by GitHub
co-authored by Renn F
parent d80dfb8bbe
commit bb3b4b0c6d
35 changed files with 2100 additions and 20 deletions
@@ -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&apos;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>
);
+14 -1
View File
@@ -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),
+2
View File
@@ -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,
+48
View File
@@ -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()) {
+30
View File
@@ -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;
},
};
+4
View File
@@ -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
+9
View File
@@ -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,
+56
View File
@@ -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
# =============================================================================
+61
View File
@@ -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)
+40
View File
@@ -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.
+16
View File
@@ -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="")
+25
View File
@@ -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
+21
View File
@@ -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(
+12 -1
View File
@@ -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 []),
)
+40
View File
@@ -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
View File
@@ -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 {}
+1
View File
@@ -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)
+114
View File
@@ -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)
+109
View File
@@ -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"])
+6 -2
View File
@@ -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()
+3 -2
View File
@@ -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