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"; } from "@/components/ui/collapsible";
import { XCredentialsForm } from "@/components/settings/x-credentials-card"; import { XCredentialsForm } from "@/components/settings/x-credentials-card";
import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card"; import { TikTokCredentialsForm } from "@/components/settings/tiktok-credentials-card";
import { TelegramCredentialsForm } from "@/components/settings/telegram-credentials-card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Flag, ChevronDown, ChevronRight } from "lucide-react"; import { Flag, ChevronDown, ChevronRight } from "lucide-react";
import { toast } from "sonner"; 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.", "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: 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.", "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() { export function FeatureFlagsCard() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [xCredsOpen, setXCredsOpen] = useState(false); const [xCredsOpen, setXCredsOpen] = useState(false);
const [tiktokCredsOpen, setTiktokCredsOpen] = useState(false); const [tiktokCredsOpen, setTiktokCredsOpen] = useState(false);
const [telegramCredsOpen, setTelegramCredsOpen] = useState(false);
// Off-transition awaiting operator confirm. Null = no dialog open. // Off-transition awaiting operator confirm. Null = no dialog open.
const [confirmFlag, setConfirmFlag] = useState<FeatureFlag | null>(null); const [confirmFlag, setConfirmFlag] = useState<FeatureFlag | null>(null);
// Every in-flight toggle key — added on mutate, removed on settle. Tracks // Every in-flight toggle key — added on mutate, removed on settle. Tracks
@@ -170,12 +174,13 @@ export function FeatureFlagsCard() {
{flags.map((flag) => { {flags.map((flag) => {
const isXEngine = flag.key === "x_engine_enabled"; const isXEngine = flag.key === "x_engine_enabled";
const isVideoEngine = flag.key === "video_engine_enabled"; const isVideoEngine = flag.key === "video_engine_enabled";
const isTelegram = flag.key === "telegram_enabled";
return ( return (
<div <div
key={flag.key} key={flag.key}
className={cn( className={cn(
"rounded-lg border p-4", "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"> <div className="flex items-start justify-between gap-4">
@@ -251,6 +256,31 @@ export function FeatureFlagsCard() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </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> </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 { TabNotes } from "./tab-notes";
export { TabDependencies } from "./tab-dependencies"; export { TabDependencies } from "./tab-dependencies";
export { TabFindings } from "./tab-findings"; export { TabFindings } from "./tab-findings";
export { TabCollision } from "./tab-collision";
export { AcceptanceCriteria } from "./acceptance-criteria"; export { AcceptanceCriteria } from "./acceptance-criteria";
export { ProgressTimeline } from "./progress-timeline"; export { ProgressTimeline } from "./progress-timeline";
export { CheckpointCard } from "./checkpoint-card"; 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 { TabNotes } from "./tab-notes";
import { TabDependencies } from "./tab-dependencies"; import { TabDependencies } from "./tab-dependencies";
import { TabFindings } from "./tab-findings"; import { TabFindings } from "./tab-findings";
import { TabCollision } from "./tab-collision";
import { import {
FileText, FileText,
Layout, Layout,
@@ -25,6 +26,7 @@ import {
StickyNote, StickyNote,
Link2, Link2,
ListChecks, ListChecks,
GitBranch,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
@@ -108,6 +110,12 @@ export function TaskTabs({ task }: TaskTabsProps) {
hint: "Revision-findings ledger — QA / PR-review / PM / CEO bounce feedback", hint: "Revision-findings ledger — QA / PR-review / PM / CEO bounce feedback",
count: findingsCount > 0 ? findingsCount : undefined, 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, // The active tab lives in the URL (?tab=) so it survives reloads,
@@ -128,7 +136,7 @@ export function TaskTabs({ task }: TaskTabsProps) {
return ( return (
<Tabs value={activeTab} onValueChange={handleTabChange} className="mt-6"> <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) => ( {tabs.map((tab) => (
<Tooltip key={tab.value}> <Tooltip key={tab.value}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -169,6 +177,9 @@ export function TaskTabs({ task }: TaskTabsProps) {
<TabsContent value="findings"> <TabsContent value="findings">
<TabFindings task={task} /> <TabFindings task={task} />
</TabsContent> </TabsContent>
<TabsContent value="collision">
<TabCollision task={task} />
</TabsContent>
</div> </div>
</Tabs> </Tabs>
); );
+14 -1
View File
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; 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 { import {
Team, Team,
TaskStatus, TaskStatus,
@@ -29,6 +29,7 @@ export const taskKeys = {
[...taskKeys.all, "subtasks", parentId] as const, [...taskKeys.all, "subtasks", parentId] as const,
boardReview: (id: string) => [...taskKeys.all, "board-review", id] as const, boardReview: (id: string) => [...taskKeys.all, "board-review", id] as const,
findings: (id: string) => [...taskKeys.all, "findings", 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, stats: () => [...taskKeys.all, "stats"] as const,
statsByTeam: () => [...taskKeys.all, "stats-by-team"] 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) { export function useSubtasks(parentTaskId: string) {
return useQuery({ return useQuery({
queryKey: taskKeys.subtasks(parentTaskId), queryKey: taskKeys.subtasks(parentTaskId),
+2
View File
@@ -31,6 +31,8 @@ export type {
XPostHistoryEntry, XPostHistoryEntry,
XCredentialsStatus, XCredentialsStatus,
} from "./x"; } from "./x";
export { telegramApi } from "./telegram";
export type { TelegramCredentialsStatus } from "./telegram";
export { roadmapApi } from "./roadmap"; export { roadmapApi } from "./roadmap";
export type { export type {
RoadmapCycle, RoadmapCycle,
+48
View File
@@ -75,6 +75,34 @@ export interface TaskFindingsResponse {
truncated: boolean; 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 // Wire shape of GET /tasks/summary (backend TaskSummaryResponse) — exactly
// the fields list views render; everything fat stays on GET /tasks/{id}. // the fields list views render; everything fat stays on GET /tasks/{id}.
interface TaskSummaryWire { interface TaskSummaryWire {
@@ -213,6 +241,26 @@ export const tasksApi = {
return data; 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 task
create: async (task: TaskCreate): Promise<Task> => { create: async (task: TaskCreate): Promise<Task> => {
if (isMockMode()) { 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 # signature for keyword-argument compatibility (mypy override check), but the
# stub bodies are empty — ARG002 would require renaming them, which breaks mypy. # stub bodies are empty — ARG002 would require renaming them, which breaks mypy.
"tests/unit/services/test_optimal_grounding.py" = ["ARG002"] "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 # 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.stream import router as stream_router
from roboco.api.routes.system import router as system_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.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.usage import router as usage_router
from roboco.api.routes.v1 import do as do_module from roboco.api.routes.v1 import do as do_module
from roboco.api.routes.v1 import flow_auditor as flow_auditor_module from roboco.api.routes.v1 import flow_auditor as flow_auditor_module
@@ -478,6 +479,14 @@ def create_app() -> FastAPI:
tags=["TikTok"], 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. # Pitches — Board proposals + CEO approve -> auto-provision origination path.
app.include_router( app.include_router(
pitch_router, pitch_router,
+56
View File
@@ -23,6 +23,8 @@ from roboco.api.schemas.tasks import (
CancelTaskRequest, CancelTaskRequest,
CheckpointRequest, CheckpointRequest,
ClaimRequest, ClaimRequest,
CollisionMapResponse,
CollisionSibling,
CommitRequest, CommitRequest,
CompleteTaskRequest, CompleteTaskRequest,
EscalateRequest, EscalateRequest,
@@ -65,6 +67,7 @@ from roboco.services.base import (
UnauthorizedError, UnauthorizedError,
ValidationError, ValidationError,
) )
from roboco.services.gateway.choreographer.collision import build_collision_context
from roboco.services.journal import get_journal_service from roboco.services.journal import get_journal_service
from roboco.services.notification_delivery import ( from roboco.services.notification_delivery import (
EscalationError, 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 # 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 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: def convert_plan(plan_data: dict | None) -> TaskPlanResponse | None:
"""Convert plan JSON dict to TaskPlanResponse. """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", 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 # Gateway coordination thresholds
# Single source of truth for "claim heartbeat is stale", consumed via # Single source of truth for "claim heartbeat is stale", consumed via
# `claimant_lock.is_stale` wherever a claim's freshness gates an action # `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): class XSeenMentionTable(Base):
"""Dedup ledger for the mentions poll — one row per mention id the engine """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 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 import findings as findings_lib
from roboco.services.gateway.choreographer._protocol import actor_context_fields from roboco.services.gateway.choreographer._protocol import actor_context_fields
from roboco.services.gateway.choreographer._verb_runner import VerbRunner 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 ( from roboco.services.gateway.claim_guards import (
already_active_guard, already_active_guard,
paused_tasks_guard, paused_tasks_guard,
@@ -969,6 +970,13 @@ class Choreographer:
# can tell "searched, nothing" (below_floor / empty) from "search broke" # can tell "searched, nothing" (below_floor / empty) from "search broke"
# (error) from "subsystem off" (disabled); lessons is empty unless ok. # (error) from "subsystem off" (disabled); lessons is empty unless ok.
briefing = {**briefing, "institutional_memory": memory_block} 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: if include_ac_coverage and task_id is not None:
coverage = await self.task.parent_ac_coverage(task_id) coverage = await self.task.parent_ac_coverage(task_id)
if coverage: if coverage:
@@ -985,6 +993,42 @@ class Choreographer:
} }
return briefing 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( async def _resolve_company_goals(
self, self,
heavy: dict[str, Any], 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, validate_findings,
) )
from roboco.services.gateway.choreographer import findings as findings_lib 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.envelope import Envelope
from roboco.services.gateway.evidence_builder import render_findings from roboco.services.gateway.evidence_builder import render_findings
from roboco.services.gateway.merge_chain import resolve_parent_branch 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)) logger.warning("gate_diff_parent_skip", task_id=str(t.id), error=str(exc))
return None 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]: async def _build_gate_review_evidence(self, t: Any) -> dict[str, Any]:
"""Inline evidence for claim_gate_review: the assembled diff + """Inline evidence for claim_gate_review: the assembled diff +
criteria + the task's OPEN findings (so they aren't crowded out by 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 newest round first) so the reviewer verifies prior rounds
item-by-item parity with QA's ``_build_qa_claim_evidence``.""" item-by-item parity with QA's ``_build_qa_claim_evidence``."""
diff = "" diff = ""
files_changed: list[str] = []
if t.branch_name: if t.branch_name:
gate_parent = await self._gate_diff_parent(t)
diff = await self.git.diff( diff = await self.git.diff(
branch_name=t.branch_name, 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( open_findings = await findings_lib.open_findings_for_task(
self.task.session, t.id self.task.session, t.id
) )
@@ -1225,4 +1270,7 @@ class PRGateMixin(_Base):
evidence["description"] = description evidence["description"] = description
if parent_context: if parent_context:
evidence["parent_context"] = parent_context evidence["parent_context"] = parent_context
collision = await self._gate_collision_evidence(t, files_changed)
if collision:
evidence["collision_context"] = collision
return evidence return evidence
@@ -52,6 +52,7 @@ from roboco.foundation.policy.content import (
from roboco.services.content_notes import apply_structured_note from roboco.services.content_notes import apply_structured_note
from roboco.services.gateway.choreographer import findings as findings_lib 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._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.envelope import Envelope
from roboco.services.gateway.evidence_builder import build_evidence_for_task 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( prior_findings = await findings_lib.full_ledger_for_task(
self.task.session, t.id 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( return build_evidence_for_task(
t, t,
journal_highlights=journal_highlights, journal_highlights=journal_highlights,
@@ -247,6 +263,7 @@ class QAMixin(_Base):
revision_findings=open_findings, revision_findings=open_findings,
prior_findings=prior_findings, prior_findings=prior_findings,
parent_context=parent_context, parent_context=parent_context,
collision_context=collision_context,
) )
async def _verify_qa_owner( async def _verify_qa_owner(
+12 -1
View File
@@ -25,6 +25,7 @@ _EVIDENCE_OMIT_WHEN_EMPTY = (
"prior_findings", "prior_findings",
"parent_context", "parent_context",
"description", "description",
"collision_context",
) )
@@ -58,6 +59,11 @@ class EvidencePayload:
# newest round first, so the round-N+1 reviewer verifies prior rounds # newest round first, so the round-N+1 reviewer verifies prior rounds
# item-by-item instead of seeing only what is still open. # item-by-item instead of seeing only what is still open.
prior_findings: list[dict[str, Any]] = field(default_factory=list) 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]: def as_dict(self) -> dict[str, Any]:
data = asdict(self) data = asdict(self)
@@ -160,6 +166,7 @@ def build_evidence_for_task(
revision_findings: list[Any] | None = None, revision_findings: list[Any] | None = None,
prior_findings: list[Any] | None = None, prior_findings: list[Any] | None = None,
parent_context: list[dict[str, Any]] | None = None, parent_context: list[dict[str, Any]] | None = None,
collision_context: list[dict[str, Any]] | None = None,
) -> EvidencePayload: ) -> EvidencePayload:
"""Compose an EvidencePayload from a Task model + supplemental data. """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 caller fetches; this module stays DB-free) and render them via
``render_findings``. ``parent_context`` is the upstream ``description`` ``render_findings``. ``parent_context`` is the upstream ``description``
chain (parent root) the caller fetches via EvidenceRepo so the dev 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( return EvidencePayload(
pr_number=task.pr_number, pr_number=task.pr_number,
@@ -183,6 +193,7 @@ def build_evidence_for_task(
convention_findings=list(convention_findings or []), convention_findings=list(convention_findings or []),
revision_findings=render_findings(revision_findings), revision_findings=render_findings(revision_findings),
prior_findings=render_findings(prior_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 asyncio
import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
@@ -874,6 +875,43 @@ class NotificationDeliveryService(BaseService):
escalator_slug=escalator.slug, 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( async def notify_ceo_of_escalation(
self, self,
*, *,
@@ -904,6 +942,7 @@ class NotificationDeliveryService(BaseService):
requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL], requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.APPROVAL],
) )
await self._persist_and_deliver(notification) 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: async def notify_ceo_of_completion(self, *, task: TaskTable, task_id: UUID) -> None:
"""CEO-facing completion notification with the granular effort breakdown. """CEO-facing completion notification with the granular effort breakdown.
@@ -933,6 +972,7 @@ class NotificationDeliveryService(BaseService):
requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.ALERT], requires_ack=ACK_REQUIRED_BY_TYPE[NotificationType.ALERT],
) )
await self._persist_and_deliver(notification) await self._persist_and_deliver(notification)
await self._notify_telegram(task_id=task_id, subject=notification.subject)
async def notify_auditor_of_rework( async def notify_auditor_of_rework(
self, 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): class ProductService(BaseService):
service_name: ClassVar[str] = "product" service_name: ClassVar[str] = "product"
@@ -114,17 +130,7 @@ class ProductService(BaseService):
per product. Returns {product_id: {done, active, blocked}}. per product. Returns {product_id: {done, active, blocked}}.
""" """
# distinct (product_id, project_id) pairs — dedup the monorepo case. # distinct (product_id, project_id) pairs — dedup the monorepo case.
proj_to_products: dict[UUID, list[UUID]] = {} proj_to_products = _project_to_products_map(products)
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)
)
if not proj_to_products: if not proj_to_products:
return {} return {}
+1
View File
@@ -80,6 +80,7 @@ FEATURE_FLAGS: tuple[tuple[str, str], ...] = (
("vault_intake_enabled", "Vault intake watcher (notes -> held drafts)"), ("vault_intake_enabled", "Vault intake watcher (notes -> held drafts)"),
("vault_report_enabled", "Vault weekly org-report note"), ("vault_report_enabled", "Vault weekly org-report note"),
("vault_kb_enabled", "Vault KB ingest (CEO notes -> RAG)"), ("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) _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 __future__ import annotations
from typing import TYPE_CHECKING, cast
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import UUID from uuid import UUID
import pytest import pytest
from roboco.services.product import ProductService from roboco.services.product import ProductService
if TYPE_CHECKING:
from roboco.db.tables import ProductTable
_PRODUCT_A = UUID("11111111-1111-1111-1111-111111111111") _PRODUCT_A = UUID("11111111-1111-1111-1111-111111111111")
_PRODUCT_B = UUID("22222222-2222-2222-2222-222222222222") _PRODUCT_B = UUID("22222222-2222-2222-2222-222222222222")
_PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") _PROJECT_1 = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
@@ -25,11 +29,11 @@ def _cell(project_id: UUID) -> MagicMock:
return cell return cell
def _product(pid: UUID, project_ids: list[UUID]) -> MagicMock: def _product(pid: UUID, project_ids: list[UUID]) -> ProductTable:
p = MagicMock() p = MagicMock()
p.id = pid p.id = pid
p.cells = [_cell(pid_proj) for pid_proj in project_ids] p.cells = [_cell(pid_proj) for pid_proj in project_ids]
return p return cast("ProductTable", p)
def _result_fetchall(rows: list[MagicMock]) -> MagicMock: 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 from __future__ import annotations
import datetime import datetime
from typing import cast
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from uuid import UUID from uuid import UUID
@@ -349,7 +350,7 @@ class TestGetTimeSeries:
) )
svc = _service_with_execute(_result_fetchall([row])) svc = _service_with_execute(_result_fetchall([row]))
await svc.get_time_series("7d", agent_slug="be-dev-1") 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})) sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert "be-dev-1" in sql assert "be-dev-1" in sql
@@ -366,7 +367,7 @@ class TestGetTimeSeries:
) )
svc = _service_with_execute(_result_fetchall([row])) svc = _service_with_execute(_result_fetchall([row]))
await svc.get_time_series("7d") 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})) sql = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert "agent_slug" not in sql assert "agent_slug" not in sql