fix(crm-vivo): os quatro canais mortos passam pelo hook curado

A correção do realtime (24b9ec2 — `setAuth` antes do `subscribe`) mora em
`useRealtimeChannel`, e curava os 5 hooks que o usavam. QUATRO lugares abriam
`.channel()` direto no cliente de navegador e ficaram de fora: assinavam como
ANÔNIMOS, recebiam "ok", logavam "subscribed" e nunca entregavam evento.

Provado morto pelo @QAVivo com A/B na MESMA página, mesma sessão, mesmo socket: o
canal do board (curado) ia com token, o de `useAgentRuns` ia sem — e um UPDATE real
na tabela, dentro do filtro do canal, não produziu quadro nenhum.

CONSEQUÊNCIA AO USUÁRIO, e é a que justifica o conserto: a tela de execuções promete
acompanhamento ao vivo e dispara "nova execução iniciada" / "execução concluída".
Esses avisos NUNCA apareceram. Quem abria para acompanhar um agente trabalhando via
lista parada e concluía que nada estava acontecendo — enquanto o agente rodava.
Não é recurso ausente; é recurso que APARENTA existir e mente por omissão.

Migrados: useAlertsRealtime, useTenantHealth (broadcast), useAgentRuns e a tela de
fontes de conhecimento (postgres_changes com filtro).

Depois disto, o único `.channel()` do repositório é o de dentro do próprio hook —
consumidores foram de 5 para 9. Assim a correção deixa de depender de alguém lembrar
que existe um caminho curado.

unit 918/918 · lint 0 erros · typecheck 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gpdoAwEGCcM9DwzC6qtkL
This commit is contained in:
Rafael Melgaço
2026-07-25 06:59:38 -03:00
co-authored by Claude Opus 5
parent f017ac3240
commit 6ca75d8535
4 changed files with 82 additions and 96 deletions
+18 -24
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect } from "react";
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { createClient } from "@/lib/supabase/browser";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import {
sourcesQueryKey,
useKnowledgeSources,
@@ -34,30 +34,24 @@ export function KnowledgeSourcesClient({ agentId, initialSources }: Props) {
const { data: sources } = useKnowledgeSources(agentId, { initialData: initialSources });
const reindex = useReindexSource(agentId);
// Realtime subscription.
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel(`ai-knowledge-sources-${agentId}`)
.on(
"postgres_changes" as never,
{
event: "*",
schema: "public",
table: "ai_knowledge_sources",
filter: `agent_id=eq.${agentId}`,
},
() => {
qc.invalidateQueries({ queryKey: sourcesQueryKey(agentId) });
},
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
const onChange = useCallback(() => {
qc.invalidateQueries({ queryKey: sourcesQueryKey(agentId) });
}, [agentId, qc]);
// Pelo hook compartilhado: `.channel()` cru assina como ANÔNIMO (cookie de
// sessão httpOnly) — recebe "ok" e nunca entrega. Aqui o efeito era a lista de
// fontes não atualizar sozinha depois de uma reindexação.
useRealtimeChannel({
name: `ai-knowledge-sources-${agentId}`,
postgresChanges: {
event: "*",
schema: "public",
table: "ai_knowledge_sources",
filter: `agent_id=eq.${agentId}`,
},
onChange,
});
const list = sources ?? [];
const bySlot: Record<KnowledgeSourceType, SourceRow | undefined> = {
+35 -36
View File
@@ -8,12 +8,12 @@
* controla subscribe/unsubscribe pra não vazar canais quando a tab Runs não
* está ativa.
*/
import { useEffect } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { createClient } from "@/lib/supabase/browser";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
export type RunStatus =
| "pending"
@@ -77,41 +77,40 @@ export function useAgentRuns(
enabled: !!agentId && enabled,
});
useEffect(() => {
if (!agentId || !realtime) return;
const supabase = createClient();
const channel = supabase
.channel(`ai-agent-runs-${agentId}`)
.on(
"postgres_changes" as never,
{
event: "*",
schema: "public",
table: "ai_agent_runs",
filter: `agent_id=eq.${agentId}`,
},
(payload: { eventType?: string; new?: AgentRunRow }) => {
qc.invalidateQueries({ queryKey: agentRunsKey(agentId) });
if (payload?.eventType === "INSERT" && !payload.new?.is_dry_run) {
toast.info("Nova execução iniciada.");
}
if (payload?.eventType === "UPDATE" && payload.new?.status === "completed") {
toast.success("Execução concluída.");
}
if (
payload?.eventType === "UPDATE" &&
(payload.new?.status === "failed" || payload.new?.status === "aborted")
) {
toast.error(`Execução ${payload.new?.status}.`);
}
},
)
.subscribe();
const onChange = useCallback(
(raw: unknown) => {
const payload = raw as { eventType?: string; new?: AgentRunRow } | null;
qc.invalidateQueries({ queryKey: agentRunsKey(agentId) });
if (payload?.eventType === "INSERT" && !payload.new?.is_dry_run) {
toast.info("Nova execução iniciada.");
}
if (payload?.eventType === "UPDATE" && payload.new?.status === "completed") {
toast.success("Execução concluída.");
}
if (
payload?.eventType === "UPDATE" &&
(payload.new?.status === "failed" || payload.new?.status === "aborted")
) {
toast.error(`Execução ${payload.new?.status}.`);
}
},
[agentId, qc],
);
return () => {
supabase.removeChannel(channel);
};
}, [agentId, realtime, qc]);
// Pelo hook compartilhado. Este canal foi o PROVADO MORTO pelo @QAVivo: A/B na
// mesma página, mesma sessão, mesmo socket — o do board (curado) ia com token,
// este ia sem. Um UPDATE real na tabela, dentro do filtro, não produziu quadro
// nenhum. Consequência ao usuário: a tela promete acompanhamento ao vivo, e os
// avisos abaixo NUNCA apareceram — quem abria para ver o agente trabalhando via
// lista parada e concluía que nada estava acontecendo.
useRealtimeChannel({
name: agentId ? `ai-agent-runs-${agentId}` : "ai-agent-runs-disabled",
postgresChanges: agentId
? { event: "*", schema: "public", table: "ai_agent_runs", filter: `agent_id=eq.${agentId}` }
: undefined,
onChange,
enabled: !!agentId && realtime,
});
return query;
}
+18 -20
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect } from "react";
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createClient } from "@/lib/supabase/browser";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import type { AlertKind } from "@/app/api/v1/admin/dashboard/kpis/route";
const KIND_LABELS: Record<AlertKind, string> = {
@@ -20,23 +20,21 @@ interface AlertBroadcast {
export function useAlertsRealtime() {
const qc = useQueryClient();
useEffect(() => {
const supabase = createClient();
const channel = supabase
.channel("alerts-platform")
.on("broadcast", { event: "*" }, (payload: { payload?: AlertBroadcast }) => {
const data = payload?.payload;
const kind = data?.kind;
const label = kind ? (KIND_LABELS[kind] ?? kind) : "alerta";
toast.warning(`Novo ${label}`, {
description: data?.message,
});
qc.invalidateQueries({ queryKey: ["admin", "dashboard", "kpis"] });
})
.subscribe();
const onChange = useCallback(
(payload: unknown) => {
const data = (payload as { payload?: AlertBroadcast } | null)?.payload;
const kind = data?.kind;
const label = kind ? (KIND_LABELS[kind] ?? kind) : "alerta";
toast.warning(`Novo ${label}`, { description: data?.message });
qc.invalidateQueries({ queryKey: ["admin", "dashboard", "kpis"] });
},
[qc],
);
return () => {
supabase.removeChannel(channel);
};
}, [qc]);
// Pelo hook compartilhado, e não por `.channel()` cru: o cookie de sessão é
// httpOnly, então o supabase-js do browser não enxerga a sessão e assina como
// ANÔNIMO — canal que recebe "ok", loga "subscribed" e nunca entrega evento.
// A correção (24b9ec2) mora em `useRealtimeChannel`, que chama `setAuth` antes
// do `subscribe`; quem abre canal direto ficou de fora dela.
useRealtimeChannel({ name: "alerts-platform", broadcast: { event: "*" }, onChange });
}
+11 -16
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useCallback } from "react";
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
import { createClient } from "@/lib/supabase/browser";
import { useRealtimeChannel } from "@/hooks/realtime/useRealtimeChannel";
import type { TenantHealthResponse } from "@/app/api/v1/admin/tenants/[id]/health/route";
export type { TenantHealthResponse };
@@ -28,20 +28,15 @@ export function useTenantHealth(id: string) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryClient, id]);
// Realtime: subscribe to broadcast channel; any event triggers a refetch
useEffect(() => {
if (!id) return;
const supabase = createClient();
const channelName = `tenant-health-${id}`;
const channel = supabase
.channel(channelName)
.on("broadcast", { event: "*" }, invalidate)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [id, invalidate]);
// Pelo hook compartilhado: `.channel()` cru assina como ANÔNIMO (cookie de
// sessão httpOnly), recebe "ok" e nunca entrega evento. A correção mora em
// `useRealtimeChannel`, que chama `setAuth` antes do `subscribe`.
useRealtimeChannel({
name: id ? `tenant-health-${id}` : "tenant-health-disabled",
broadcast: { event: "*" },
onChange: invalidate,
enabled: !!id,
});
return query;
}