diff --git a/src/components/mika/cronjobs/CronjobCard.tsx b/src/components/mika/cronjobs/CronjobCard.tsx index c59ba99..955e723 100644 --- a/src/components/mika/cronjobs/CronjobCard.tsx +++ b/src/components/mika/cronjobs/CronjobCard.tsx @@ -23,6 +23,7 @@ import { useUpdateCronjobStatus, } from "@/hooks/use-cronjobs"; import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations"; +import { syncAgentRuntime } from "@/lib/sync-agent-runtime"; interface Props { job: ScheduledJob; @@ -45,6 +46,8 @@ export function CronjobCard({ job }: Props) { const isActive = job.status === "active"; const isAutoPaused = job.status === "auto_paused"; + const hasRuntimeError = job.runtime_state === "error" || job.runtime_last_status === "error"; + const runtimeErrorText = job.runtime_last_delivery_error ?? job.runtime_last_error; async function toggle() { try { @@ -53,6 +56,11 @@ export function CronjobCard({ job }: Props) { status: isActive ? "paused" : "active", }); toast.success(isActive ? "Automação pausada." : "Automação ativada."); + + const { error: syncError } = await syncAgentRuntime(job.agent_instance_id, "cronjobs"); + if (syncError) { + toast.warning("Status salvo, mas o runtime do agente não sincronizou."); + } } catch (e) { toast.error(e instanceof Error ? e.message : "Erro ao atualizar."); } @@ -63,6 +71,11 @@ export function CronjobCard({ job }: Props) { await deleteMut.mutateAsync(job.id); toast.success("Automação excluída."); setConfirmDelete(false); + + const { error: syncError } = await syncAgentRuntime(job.agent_instance_id, "cronjobs"); + if (syncError) { + toast.warning("Automação removida, mas o runtime do agente não sincronizou."); + } } catch (e) { toast.error(e instanceof Error ? e.message : "Erro ao excluir."); } @@ -76,11 +89,15 @@ export function CronjobCard({ job }: Props) {

{job.name}

{isActive && Ativa} {job.status === "paused" && Pausada} + {job.status === "error" && Erro} {isAutoPaused && ( Auto-pausada )} + {hasRuntimeError && !isAutoPaused && ( + Runtime com erro + )}

{job.human_readable} @@ -92,6 +109,10 @@ export function CronjobCard({ job }: Props) {

{job.auto_paused_reason}

)} + {hasRuntimeError && runtimeErrorText && ( +

{runtimeErrorText}

+ )} + {missingMcps.length > 0 && isActive && (
diff --git a/src/components/mika/cronjobs/CronjobWizard.tsx b/src/components/mika/cronjobs/CronjobWizard.tsx index 69d40f0..5b8aff8 100644 --- a/src/components/mika/cronjobs/CronjobWizard.tsx +++ b/src/components/mika/cronjobs/CronjobWizard.tsx @@ -10,6 +10,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { invokeFunction } from "@/lib/invoke-function"; +import { syncAgentRuntime } from "@/lib/sync-agent-runtime"; import { useCreateCronjob } from "@/hooks/use-cronjobs"; import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations"; import { useAgentInstance } from "@/hooks/use-agent-instance"; @@ -131,6 +132,12 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { next_run_at: parsed?.next_run_at ?? null, }); toast.success("Automação criada!"); + + const { error: syncError } = await syncAgentRuntime(agent.id, "cronjobs"); + if (syncError) { + toast.warning("Automação criada, mas o runtime do agente não sincronizou."); + } + onCreated?.(job.id); } catch (e) { const msg = e instanceof Error ? e.message : "Erro ao criar automação"; diff --git a/src/components/mika/integrations/DisconnectMCPDialog.tsx b/src/components/mika/integrations/DisconnectMCPDialog.tsx index 8b94891..7551e16 100644 --- a/src/components/mika/integrations/DisconnectMCPDialog.tsx +++ b/src/components/mika/integrations/DisconnectMCPDialog.tsx @@ -43,7 +43,11 @@ export function DisconnectMCPDialog({ async function handleDisconnect() { setSubmitting(true); - const { error } = await invokeFunction("disconnect-integration", { + const { data, error } = await invokeFunction<{ + success: boolean; + paused_jobs_count: number; + runtime_sync_warning?: string | null; + }>("disconnect-integration", { integration_id: integrationId, }); setSubmitting(false); @@ -52,6 +56,9 @@ export function DisconnectMCPDialog({ return; } toast.success(`${mcpName} desconectado.`); + if (data?.runtime_sync_warning) { + toast.warning("Integração removida, mas o runtime do agente não sincronizou."); + } queryClient.invalidateQueries({ queryKey: ["user-integrations"] }); queryClient.invalidateQueries({ queryKey: ["user-integration-limits"] }); onOpenChange(false); diff --git a/src/components/mika/skills/SkillCard.tsx b/src/components/mika/skills/SkillCard.tsx index 09de28e..687071f 100644 --- a/src/components/mika/skills/SkillCard.tsx +++ b/src/components/mika/skills/SkillCard.tsx @@ -8,6 +8,7 @@ import { formatDistanceToNow } from "date-fns"; import { ptBR } from "date-fns/locale"; import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; +import { syncAgentSkills } from "@/lib/sync-agent-skills"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -39,6 +40,15 @@ export function SkillCard({ skill }: { skill: Skill }) { const isArchived = skill.status === "archived"; + async function syncRuntimeAfterMutation(actionLabel: string) { + const { error } = await syncAgentSkills(skill.agent_instance_id); + if (error) { + toast.warning(`${actionLabel}, mas o sync com o container falhou.`, { + description: error.message, + }); + } + } + const updateStatus = useMutation({ mutationFn: async (newStatus: string) => { const { error } = await supabase @@ -56,16 +66,21 @@ export function SkillCard({ skill }: { skill: Skill }) { const handleToggleActive = () => { const next = skill.status === "active" ? "disabled" : "active"; updateStatus.mutate(next, { - onSuccess: () => toast.success(next === "active" ? "Skill ativada" : "Skill desativada"), + onSuccess: async () => { + const actionLabel = next === "active" ? "Skill ativada" : "Skill desativada"; + toast.success(actionLabel); + await syncRuntimeAfterMutation(actionLabel); + }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"), }); }; const handleArchive = () => { updateStatus.mutate("archived", { - onSuccess: () => { + onSuccess: async () => { toast.success("Skill arquivada"); setConfirmArchive(false); + await syncRuntimeAfterMutation("Skill arquivada"); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao arquivar"), }); @@ -73,7 +88,10 @@ export function SkillCard({ skill }: { skill: Skill }) { const handleRestore = () => { updateStatus.mutate("draft", { - onSuccess: () => toast.success("Skill restaurada como rascunho"), + onSuccess: async () => { + toast.success("Skill restaurada como rascunho"); + await syncRuntimeAfterMutation("Skill restaurada como rascunho"); + }, onError: (e: unknown) => { if ((e as { code?: string })?.code === "23505") { toast.error("Já existe outra skill ativa com esse nome. Renomeie antes de restaurar."); @@ -89,11 +107,12 @@ export function SkillCard({ skill }: { skill: Skill }) { const { error } = await supabase.from("skills").delete().eq("id", skill.id); if (error) throw error; }, - onSuccess: () => { + onSuccess: async () => { toast.success("Skill deletada"); qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["user-limits"] }); setConfirmDelete(false); + await syncRuntimeAfterMutation("Skill deletada"); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"), }); diff --git a/src/hooks/use-cronjobs.ts b/src/hooks/use-cronjobs.ts index 694a2fc..6dd65c7 100644 --- a/src/hooks/use-cronjobs.ts +++ b/src/hooks/use-cronjobs.ts @@ -15,10 +15,15 @@ export interface ScheduledJob { human_readable: string; action_prompt: string; required_mcp_slugs: string[]; - status: "active" | "paused" | "auto_paused"; + status: "active" | "paused" | "auto_paused" | "error" | "archived"; auto_paused_reason: string | null; last_run_at: string | null; next_run_at: string | null; + runtime_state: "scheduled" | "paused" | "completed" | "error" | null; + runtime_last_status: "ok" | "error" | null; + runtime_last_error: string | null; + runtime_last_delivery_error: string | null; + runtime_synced_at: string | null; timezone: string; created_at: string; updated_at: string; diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 23a8b39..9e43fae 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -475,6 +475,11 @@ export type Database = { natural_language_input: string next_run_at: string | null required_mcp_slugs: Json + runtime_last_delivery_error: string | null + runtime_last_error: string | null + runtime_last_status: string | null + runtime_state: string | null + runtime_synced_at: string | null status: string timezone: string updated_at: string @@ -494,6 +499,11 @@ export type Database = { natural_language_input: string next_run_at?: string | null required_mcp_slugs?: Json + runtime_last_delivery_error?: string | null + runtime_last_error?: string | null + runtime_last_status?: string | null + runtime_state?: string | null + runtime_synced_at?: string | null status?: string timezone?: string updated_at?: string @@ -513,6 +523,11 @@ export type Database = { natural_language_input?: string next_run_at?: string | null required_mcp_slugs?: Json + runtime_last_delivery_error?: string | null + runtime_last_error?: string | null + runtime_last_status?: string | null + runtime_state?: string | null + runtime_synced_at?: string | null status?: string timezone?: string updated_at?: string diff --git a/src/lib/ollama-models.ts b/src/lib/ollama-models.ts new file mode 100644 index 0000000..fea2d92 --- /dev/null +++ b/src/lib/ollama-models.ts @@ -0,0 +1,35 @@ +export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud"; +export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud"; + +const LEGACY_MODEL_ALIASES: Record = { + "openrouter/google/gemma-4-27b-a4b-it": DEFAULT_OLLAMA_MODEL, + "openrouter/google/gemma-4-31b-it": DEFAULT_OLLAMA_MODEL, + "ollama-cloud/gemma4:31b-cloud": DEFAULT_OLLAMA_MODEL, +}; + +export function normalizeOllamaModelSelection(value?: string | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return DEFAULT_OLLAMA_MODEL; + + const mapped = LEGACY_MODEL_ALIASES[trimmed]; + if (mapped) return mapped; + + if (trimmed.includes("/") && trimmed.includes(":")) { + const candidate = trimmed.split("/").pop()?.trim(); + if (candidate) return candidate; + } + + return trimmed; +} + +export function formatOllamaModelLabel(modelName: string): string { + if (modelName === DEFAULT_OLLAMA_MODEL) { + return "Gemma 4 31B Cloud — Padrão Mika"; + } + + if (modelName.startsWith("gemma4:")) { + return modelName.replace("gemma4:", "Gemma 4 "); + } + + return modelName; +} diff --git a/src/lib/sync-agent-runtime.ts b/src/lib/sync-agent-runtime.ts new file mode 100644 index 0000000..b4ebbf5 --- /dev/null +++ b/src/lib/sync-agent-runtime.ts @@ -0,0 +1,24 @@ +import { invokeFunction } from "@/lib/invoke-function"; + +type RuntimeSyncScope = "cronjobs" | "integrations" | "all"; + +export async function syncAgentRuntime( + agentInstanceId: string, + scope: RuntimeSyncScope = "all", +) { + return await invokeFunction<{ + success: boolean; + agent_instance_id: string; + public_url: string; + public_domain: string; + cronjobs_synced_count: number; + integrations_synced_count: number; + runtime_responses: { + cronjobs: unknown; + integrations: unknown; + }; + }>("sync-agent-runtime", { + agent_instance_id: agentInstanceId, + scope, + }); +} diff --git a/src/lib/sync-agent-skills.ts b/src/lib/sync-agent-skills.ts new file mode 100644 index 0000000..f52e289 --- /dev/null +++ b/src/lib/sync-agent-skills.ts @@ -0,0 +1,18 @@ +"use client"; + +import { invokeFunction } from "@/lib/invoke-function"; + +export interface SyncAgentSkillsResponse { + success?: boolean; + agent_instance_id?: string; + public_url?: string; + public_domain?: string; + synced_count?: number; + runtime_response?: unknown; +} + +export async function syncAgentSkills(agentInstanceId: string) { + return await invokeFunction("sync-agent-skills", { + agent_instance_id: agentInstanceId, + }); +} diff --git a/src/routes/admin.agente.$id.tsx b/src/routes/admin.agente.$id.tsx index c922d8d..aff3a95 100644 --- a/src/routes/admin.agente.$id.tsx +++ b/src/routes/admin.agente.$id.tsx @@ -16,6 +16,11 @@ import { import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/use-auth"; import { invokeFunction } from "@/lib/invoke-function"; +import { + DEFAULT_OLLAMA_MODEL, + formatOllamaModelLabel, + normalizeOllamaModelSelection, +} from "@/lib/ollama-models"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -34,19 +39,6 @@ export const Route = createFileRoute("/admin/agente/$id")({ component: AgentDetailPage, }); -const MODEL_OPTIONS = [ - { - value: "openrouter/google/gemma-4-27b-a4b-it", - label: "Gemma 4 27B — Rápido e gratuito (Basic/Starter)", - plans: ["basic", "starter"], - }, - { - value: "openrouter/google/gemma-4-31b-it", - label: "Gemma 4 31B — Mais capaz (Professional)", - plans: ["professional", "enterprise"], - }, -]; - interface AgentDetail { id: string; user_id: string; @@ -73,6 +65,15 @@ interface AgentDetail { subscription: { plans: { slug: string; name: string } | null } | null; } +interface OllamaModelRow { + name: string; + raw_name: string; + modified_at: string | null; + size: number | null; + digest: string | null; + details: Record; +} + function AgentDetailPage() { const { id } = Route.useParams(); const { user, loading: authLoading } = useAuth(); @@ -178,6 +179,19 @@ function AgentDetailPage() { }, }); + const { data: availableModels } = useQuery({ + queryKey: ["ollama-models"], + enabled: isAdmin === true, + staleTime: 5 * 60_000, + queryFn: async () => { + const { data, error } = await invokeFunction<{ + models?: OllamaModelRow[]; + }>("list-ollama-models"); + if (error) throw new Error(error.message); + return data?.models ?? []; + }, + }); + // Backfill: se o agente não tem telegram_user_chat_id mas já recebeu mensagens, // pega a primeira mensagem incoming e popula automaticamente. useEffect(() => { @@ -210,11 +224,7 @@ function AgentDetailPage() { // ===== Estado do formulário ===== const fullName = agent?.profile?.full_name?.trim() || "Usuário"; const firstName = fullName.split(" ")[0] || "Usuário"; - const planSlug = agent?.subscription?.plans?.slug ?? "basic"; - const isPro = ["professional", "enterprise"].includes(planSlug); - const defaultModel = isPro - ? "openrouter/google/gemma-4-31b-it" - : "openrouter/google/gemma-4-27b-a4b-it"; + const defaultModel = DEFAULT_OLLAMA_MODEL; const cfg = (agent?.model_config ?? {}) as Record; const defaultAgentName = agent?.agent_name?.trim() || cfg.agent_name || `Mika de ${firstName}`; @@ -231,16 +241,29 @@ function AgentDetailPage() { const [tts, setTts] = useState("disabled"); const [busy, setBusy] = useState(false); const [initialized, setInitialized] = useState(false); + const modelOptions = useMemo(() => { + const options = new Map(); + options.set(DEFAULT_OLLAMA_MODEL, formatOllamaModelLabel(DEFAULT_OLLAMA_MODEL)); + + for (const row of availableModels ?? []) { + options.set(row.name, formatOllamaModelLabel(row.name)); + } + + const currentModel = normalizeOllamaModelSelection(cfg.model || cfg.provider || defaultModel); + options.set(currentModel, formatOllamaModelLabel(currentModel)); + + return Array.from(options.entries()).map(([value, label]) => ({ value, label })); + }, [availableModels, cfg.model, cfg.provider, defaultModel]); useEffect(() => { if (!agent || initialized) return; setAgentName(defaultAgentName); setSoul(defaultSoul); - setModel(cfg.provider || defaultModel); + setModel(normalizeOllamaModelSelection(cfg.model || cfg.provider || defaultModel)); setStt(cfg.stt || "local"); setTts(cfg.tts || "disabled"); setInitialized(true); - }, [agent, initialized, defaultAgentName, defaultSoul, cfg.provider, cfg.stt, cfg.tts, defaultModel]); + }, [agent, initialized, defaultAgentName, defaultSoul, cfg.model, cfg.provider, cfg.stt, cfg.tts, defaultModel]); // ===== Auth guard ===== useEffect(() => { @@ -479,13 +502,16 @@ function AgentDetailPage() { - {MODEL_OPTIONS.map((m) => ( + {modelOptions.map((m) => ( {m.label} ))} +

+ Catálogo carregado dinamicamente do Ollama Cloud. +

diff --git a/src/routes/painel.cronjobs.$id.tsx b/src/routes/painel.cronjobs.$id.tsx index 4704c8b..a7809d9 100644 --- a/src/routes/painel.cronjobs.$id.tsx +++ b/src/routes/painel.cronjobs.$id.tsx @@ -17,11 +17,13 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { + type ScheduledJob, useCronjob, useDeleteCronjob, useUpdateCronjobStatus, } from "@/hooks/use-cronjobs"; import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations"; +import { syncAgentRuntime } from "@/lib/sync-agent-runtime"; export const Route = createFileRoute("/painel/cronjobs/$id")({ component: CronjobDetailPage, @@ -65,6 +67,7 @@ function CronjobDetailPage() { const isActive = job.status === "active"; const isAutoPaused = job.status === "auto_paused"; + const hasRuntimeError = job.runtime_state === "error" || job.runtime_last_status === "error"; async function toggle() { try { @@ -73,6 +76,11 @@ function CronjobDetailPage() { status: isActive ? "paused" : "active", }); toast.success(isActive ? "Pausada." : "Ativada."); + + const { error: syncError } = await syncAgentRuntime(job!.agent_instance_id, "cronjobs"); + if (syncError) { + toast.warning("Status salvo, mas o runtime do agente não sincronizou."); + } } catch (e) { toast.error(e instanceof Error ? e.message : "Erro"); } @@ -82,6 +90,12 @@ function CronjobDetailPage() { try { await deleteMut.mutateAsync(job!.id); toast.success("Excluída."); + + const { error: syncError } = await syncAgentRuntime(job!.agent_instance_id, "cronjobs"); + if (syncError) { + toast.warning("Automação removida, mas o runtime do agente não sincronizou."); + } + navigate({ to: "/painel/cronjobs" }); } catch (e) { toast.error(e instanceof Error ? e.message : "Erro"); @@ -102,11 +116,15 @@ function CronjobDetailPage() {
{isActive && Ativa} {job.status === "paused" && Pausada} + {job.status === "error" && Erro} {isAutoPaused && ( Auto-pausada )} + {hasRuntimeError && !isAutoPaused && ( + Runtime com erro + )}
@@ -138,6 +156,22 @@ function CronjobDetailPage() {
)} + {hasRuntimeError && ( +
+

Falha reportada pelo runtime

+

+ {job.runtime_last_error ?? "O runtime marcou a última execução como erro."} +

+
+ )} + + {job.runtime_last_delivery_error && ( +
+

Falha na entrega do resultado

+

{job.runtime_last_delivery_error}

+
+ )} + {missingMcps.length > 0 && isActive && (

@@ -182,6 +216,23 @@ function CronjobDetailPage() { : "Nunca" } /> +

+
+
{job.description &&
} @@ -265,3 +316,29 @@ function Section({
); } + +function formatRuntimeState(state: ScheduledJob["runtime_state"]): string { + switch (state) { + case "scheduled": + return "Agendado"; + case "paused": + return "Pausado"; + case "completed": + return "Concluído"; + case "error": + return "Erro"; + default: + return "Desconhecido"; + } +} + +function formatRuntimeLastStatus(status: ScheduledJob["runtime_last_status"]): string { + switch (status) { + case "ok": + return "Sucesso"; + case "error": + return "Erro"; + default: + return "Ainda sem execução"; + } +} diff --git a/src/routes/painel.integracoes.$slug.tsx b/src/routes/painel.integracoes.$slug.tsx index 508c985..0335280 100644 --- a/src/routes/painel.integracoes.$slug.tsx +++ b/src/routes/painel.integracoes.$slug.tsx @@ -119,7 +119,11 @@ function IntegrationDetailPage() { async function handleRefresh() { setRefreshing(true); - const { error } = await invokeFunction("refresh-integration-token", { + const { data, error } = await invokeFunction<{ + success: boolean; + expires_at: string | null; + runtime_sync_warning?: string | null; + }>("refresh-integration-token", { integration_id: integration!.id, }); setRefreshing(false); @@ -127,6 +131,9 @@ function IntegrationDetailPage() { toast.error(error.message); } else { toast.success("Token renovado."); + if (data?.runtime_sync_warning) { + toast.warning("Token renovado, mas o runtime do agente não sincronizou."); + } } queryClient.invalidateQueries({ queryKey: ["user-integrations"] }); } diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx index 0c1eb4b..90494f9 100644 --- a/src/routes/painel.skills.$id.tsx +++ b/src/routes/painel.skills.$id.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/hooks/use-auth"; import { useSkill } from "@/hooks/use-skills"; +import { syncAgentSkills } from "@/lib/sync-agent-skills"; import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge"; import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel"; import { Button } from "@/components/ui/button"; @@ -131,7 +132,13 @@ function SkillDetailPage() { // Publish version const publishVersion = useMutation({ mutationFn: async (versionId: string) => { - const { data, error } = await supabase.functions.invoke("publish-skill-version", { + const { data, error } = await supabase.functions.invoke<{ + success?: boolean; + no_op?: boolean; + version_number?: number; + synced?: boolean; + sync_error?: string; + }>("publish-skill-version", { body: { skill_version_id: versionId }, }); if (error) throw error; @@ -140,6 +147,10 @@ function SkillDetailPage() { onSuccess: (data) => { if (data.no_op) { toast.info("Esta versão já está publicada"); + } else if (data.synced === false) { + toast.warning(`Versão ${data.version_number} publicada, mas o sync falhou.`, { + description: data.sync_error || "Tente novamente após o próximo deploy.", + }); } else { toast.success(`Versão ${data.version_number} publicada!`); } @@ -170,6 +181,13 @@ function SkillDetailPage() { toast.success("Skill arquivada"); qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["user-limits"] }); + void syncAgentSkills(skill.data.agent_instance_id).then(({ error }) => { + if (error) { + toast.warning("Skill arquivada, mas o sync com o container falhou.", { + description: error.message, + }); + } + }); navigate({ to: "/painel/skills" }); }, onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"), diff --git a/src/routes/painel.skills.preview.tsx b/src/routes/painel.skills.preview.tsx index 38b4920..0c35c2b 100644 --- a/src/routes/painel.skills.preview.tsx +++ b/src/routes/painel.skills.preview.tsx @@ -118,12 +118,20 @@ function SkillPreviewPage() { // 3. Optionally publish if (publish) { - const { data: pubData, error: pubErr } = await supabase.functions.invoke( + const { data: pubData, error: pubErr } = await supabase.functions.invoke<{ + success?: boolean; + synced?: boolean; + sync_error?: string; + }>( "publish-skill-version", { body: { skill_version_id: ver.id } }, ); if (pubErr) { toast.error("Skill salva, mas falha ao publicar: " + pubErr.message); + } else if (pubData?.synced === false) { + toast.warning("Skill publicada, mas o sync com o container falhou.", { + description: pubData.sync_error || "Tente novamente após o próximo deploy.", + }); } else { toast.success("Skill publicada com sucesso!"); } diff --git a/supabase/functions/_shared/hermes-config.ts b/supabase/functions/_shared/hermes-config.ts new file mode 100644 index 0000000..634b583 --- /dev/null +++ b/supabase/functions/_shared/hermes-config.ts @@ -0,0 +1,23 @@ +export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud"; +export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud"; + +const LEGACY_MODEL_ALIASES: Record = { + "openrouter/google/gemma-4-27b-a4b-it": DEFAULT_OLLAMA_MODEL, + "openrouter/google/gemma-4-31b-it": DEFAULT_OLLAMA_MODEL, + "ollama-cloud/gemma4:31b-cloud": DEFAULT_OLLAMA_MODEL, +}; + +export function normalizeOllamaModelSelection(value?: string | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) return DEFAULT_OLLAMA_MODEL; + + const mapped = LEGACY_MODEL_ALIASES[trimmed]; + if (mapped) return mapped; + + if (trimmed.includes("/") && trimmed.includes(":")) { + const candidate = trimmed.split("/").pop()?.trim(); + if (candidate) return candidate; + } + + return trimmed; +} diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts index 4429b27..1fe73a6 100644 --- a/supabase/functions/_shared/railway.ts +++ b/supabase/functions/_shared/railway.ts @@ -4,15 +4,13 @@ const RAILWAY_GRAPHQL = "https://backboard.railway.app/graphql/v2"; /** * Start command padrão dos containers Hermes. - * - Verifica HERMES_SUSPENDED no início: se true, dorme infinitamente (agente "pausado") - * - Aplica HERMES_SOUL_OVERRIDE em /opt/data/SOUL.md se presente - * - Inicia o gateway Hermes + * - Encaminha para o entrypoint custom da imagem `hermes-agent-custom` + * - O próprio entrypoint aplica SOUL.md, model/provider, STT/TTS e suspensão * * IMPORTANTE: este comando deve ser idêntico ao configurado nos serviços Railway * existentes. Para serviços antigos, atualize manualmente via UI/Agent do Railway. */ -export const HERMES_START_COMMAND = - `/bin/bash -c 'if [ "$HERMES_SUSPENDED" = "true" ]; then echo "Agent suspended" && sleep infinity; fi && if [ -n "$HERMES_SOUL_OVERRIDE" ]; then echo "$HERMES_SOUL_OVERRIDE" > /opt/data/SOUL.md; fi && /opt/hermes/docker/entrypoint.sh gateway run'`; +export const HERMES_START_COMMAND = `/opt/hermes-custom/entrypoint.sh`; export interface RailwayError { message: string; @@ -308,6 +306,151 @@ export async function getServiceEnvironmentId(opts: { return (await getServiceContext(opts)).environmentId; } +export interface RailwayServiceDomainInfo { + id: string; + domain: string; + suffix?: string | null; + certificateStatus?: string | null; +} + +export async function listRailwayServiceDomains(opts: { + token: string; + serviceId: string; + environmentId: string; + projectId?: string | null; +}): Promise<{ serviceDomains: RailwayServiceDomainInfo[]; customDomains: RailwayServiceDomainInfo[] }> { + const query = ` + query Domains($environmentId: String!, $serviceId: String!, $projectId: String) { + domains(environmentId: $environmentId, serviceId: $serviceId, projectId: $projectId) { + serviceDomains { + id + domain + suffix + } + customDomains { + id + domain + status { + certificateStatus + } + } + } + } + `; + const res = await railwayQuery<{ + domains: { + serviceDomains?: { id: string; domain: string; suffix?: string | null }[]; + customDomains?: { id: string; domain: string; status?: { certificateStatus?: string | null } | null }[]; + }; + }>( + query, + { + environmentId: opts.environmentId, + serviceId: opts.serviceId, + projectId: opts.projectId ?? null, + }, + opts.token, + ); + + if (res.errors?.length) { + throw new Error(`domains query failed: ${JSON.stringify(res.errors)}`); + } + + const serviceDomains = (res.data?.domains?.serviceDomains ?? []).map((item) => ({ + id: item.id, + domain: item.domain, + suffix: item.suffix ?? null, + certificateStatus: "ISSUED", + })); + + const customDomains = (res.data?.domains?.customDomains ?? []).map((item) => ({ + id: item.id, + domain: item.domain, + certificateStatus: item.status?.certificateStatus ?? null, + })); + + return { serviceDomains, customDomains }; +} + +export async function createRailwayServiceDomain(opts: { + token: string; + serviceId: string; + environmentId: string; + targetPort?: number; +}): Promise { + const mutation = ` + mutation ServiceDomainCreate($input: ServiceDomainCreateInput!) { + serviceDomainCreate(input: $input) { + id + domain + suffix + } + } + `; + + const input: Record = { + serviceId: opts.serviceId, + environmentId: opts.environmentId, + }; + if (opts.targetPort !== undefined) { + input.targetPort = opts.targetPort; + } + + const res = await railwayQuery<{ + serviceDomainCreate: { id: string; domain: string; suffix?: string | null }; + }>(mutation, { input }, opts.token); + + if (res.errors?.length) { + throw new Error(`serviceDomainCreate failed: ${JSON.stringify(res.errors)}`); + } + + const created = res.data?.serviceDomainCreate; + if (!created?.domain) { + throw new Error("serviceDomainCreate returned no domain"); + } + + return { + id: created.id, + domain: created.domain, + suffix: created.suffix ?? null, + certificateStatus: "ISSUED", + }; +} + +export async function ensureRailwayServiceDomain(opts: { + token: string; + serviceId: string; + environmentId: string; + projectId?: string | null; + targetPort?: number; +}): Promise { + const existing = await listRailwayServiceDomains({ + token: opts.token, + serviceId: opts.serviceId, + environmentId: opts.environmentId, + projectId: opts.projectId, + }); + + const serviceDomain = existing.serviceDomains.find((item) => item.domain); + if (serviceDomain) { + return serviceDomain; + } + + const issuedCustomDomain = existing.customDomains.find((item) => + item.domain && (!item.certificateStatus || item.certificateStatus === "ISSUED") + ); + if (issuedCustomDomain) { + return issuedCustomDomain; + } + + return await createRailwayServiceDomain({ + token: opts.token, + serviceId: opts.serviceId, + environmentId: opts.environmentId, + targetPort: opts.targetPort, + }); +} + /** Apaga o webhook do Telegram para que o Hermes assuma via polling. */ export async function deleteTelegramWebhook(botToken: string): Promise { const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`; diff --git a/supabase/functions/_shared/runtime-sync.ts b/supabase/functions/_shared/runtime-sync.ts new file mode 100644 index 0000000..4c85532 --- /dev/null +++ b/supabase/functions/_shared/runtime-sync.ts @@ -0,0 +1,1035 @@ +import { + ensureRailwayServiceDomain, + getServiceContext, +} from "./railway.ts"; + +interface AgentInstanceRecord { + id: string; + user_id: string; + railway_service_id: string | null; + vps_pool_id: string | null; +} + +interface ActiveSkillRow { + id: string; + agent_instance_id: string; + name: string; + description: string; + trigger_keywords: string; + current_version_id: string; + updated_at: string; +} + +interface SkillVersionRow { + id: string; + version_number: number; + markdown_content: string; + form_inputs: Record | null; + created_at: string; +} + +interface ScheduledJobRow { + id: string; + agent_instance_id: string; + name: string; + description: string | null; + natural_language_input: string; + cron_expression: string; + human_readable: string; + action_prompt: string; + required_mcp_slugs: string[] | null; + status: string; + auto_paused_reason: string | null; + last_run_at: string | null; + next_run_at: string | null; + timezone: string; + created_at: string; + updated_at: string; +} + +interface AvailableMcpRow { + id: string; + slug: string; + name: string; + provider: string; + description: string; + oauth_authorize_url: string; + oauth_token_url: string; + oauth_revoke_url: string | null; + required_scopes: string[] | null; + supports_refresh_token: boolean; +} + +interface UserIntegrationRow { + id: string; + user_id: string; + mcp_id: string; + status: string; + connected_account_email: string | null; + connected_account_name: string | null; + granted_scopes: string[] | null; + token_expires_at: string | null; + last_refreshed_at: string | null; + error_message: string | null; + access_token_vault_id: string | null; + refresh_token_vault_id: string | null; + created_at: string; + updated_at: string; + mcp: AvailableMcpRow | AvailableMcpRow[] | null; +} + +interface RuntimeTarget { + serviceId: string; + projectId: string; + environmentId: string; + publicUrl: string; + publicDomain: string; +} + +type RuntimeSyncScope = "cronjobs" | "integrations" | "all"; + +export interface AgentSkillsSyncPayload { + agent_instance_id: string; + synced_at: string; + skills: Array<{ + skill_id: string; + version_id: string; + version_number: number; + name: string; + description: string; + trigger_keywords: string; + updated_at: string; + created_at: string; + form_inputs: Record | null; + markdown_content: string; + }>; +} + +export interface AgentSkillsSyncResult { + agent_instance_id: string; + public_url: string; + public_domain: string; + synced_count: number; + response: unknown; +} + +export interface AgentCronjobsSyncPayload { + agent_instance_id: string; + synced_at: string; + cronjobs: Array<{ + job_id: string; + name: string; + description: string | null; + natural_language_input: string; + cron_expression: string; + human_readable: string; + action_prompt: string; + required_mcp_slugs: string[]; + status: string; + auto_paused_reason: string | null; + last_run_at: string | null; + next_run_at: string | null; + timezone: string; + created_at: string; + updated_at: string; + }>; +} + +export interface AgentCronjobsSyncResult { + agent_instance_id: string; + public_url: string; + public_domain: string; + synced_count: number; + response: unknown; +} + +interface RuntimeManagedCronjobRecord { + id: string; + state?: string | null; + last_status?: string | null; + last_error?: string | null; + last_delivery_error?: string | null; + last_run_at?: string | null; + next_run_at?: string | null; +} + +interface RuntimeCronjobsListResponse { + cronjobs?: RuntimeManagedCronjobRecord[]; +} + +export interface AgentCronjobsRuntimePullResult { + agent_instance_id: string; + public_url: string; + public_domain: string; + synced_count: number; + updated_count: number; + response: unknown; +} + +interface RuntimeMcpHint { + server_url: string | null; + transport: "streamable-http" | null; + auth_mode: "oauth" | "unknown"; + notes: string[]; +} + +export interface AgentIntegrationsSyncPayload { + agent_instance_id: string; + user_id: string; + synced_at: string; + integrations: Array<{ + integration_id: string; + mcp_id: string; + slug: string; + name: string; + provider: string; + description: string; + status: string; + connected_account_email: string | null; + connected_account_name: string | null; + granted_scopes: string[]; + required_scopes: string[]; + token_expires_at: string | null; + last_refreshed_at: string | null; + error_message: string | null; + oauth_authorize_url: string; + oauth_token_url: string; + oauth_revoke_url: string | null; + supports_refresh_token: boolean; + access_token: string | null; + refresh_token: string | null; + runtime_mcp: RuntimeMcpHint; + created_at: string; + updated_at: string; + }>; +} + +export interface AgentIntegrationsSyncResult { + agent_instance_id: string; + public_url: string; + public_domain: string; + synced_count: number; + response: unknown; +} + +export interface AgentRuntimeSyncResult { + agent_instance_id: string; + public_url: string; + public_domain: string; + cronjobs_synced_count: number; + integrations_synced_count: number; + responses: { + cronjobs: unknown; + integrations: unknown; + }; +} + +function yamlQuoted(value: string): string { + return JSON.stringify(value ?? ""); +} + +function ensureStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; +} + +function ensureSkillFrontmatter(skill: { + name: string; + description: string; + trigger_keywords: string; + markdown_content: string; +}): string { + const markdown = (skill.markdown_content ?? "").trim(); + if (markdown.startsWith("---")) { + return markdown; + } + + return [ + "---", + `name: ${yamlQuoted(skill.name)}`, + `description: ${yamlQuoted(skill.description)}`, + `trigger_keywords: ${yamlQuoted(skill.trigger_keywords)}`, + "---", + "", + markdown, + ].join("\n"); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function asNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function normalizeRuntimeState(value: unknown): "scheduled" | "paused" | "completed" | "error" | null { + switch (value) { + case "scheduled": + case "paused": + case "completed": + case "error": + return value; + default: + return null; + } +} + +function normalizeRuntimeLastStatus(value: unknown): "ok" | "error" | null { + switch (value) { + case "ok": + case "error": + return value; + default: + return null; + } +} + +function getOfficialRuntimeMcpHint(slug: string): RuntimeMcpHint { + switch (slug) { + case "notion": + return { + server_url: "https://mcp.notion.com/mcp", + transport: "streamable-http", + auth_mode: "oauth", + notes: [ + "Official hosted MCP available via interactive OAuth.", + "Mika runtime currently uses native provider bridge tools for headless execution.", + ], + }; + case "todoist": + return { + server_url: "https://ai.todoist.net/mcp", + transport: "streamable-http", + auth_mode: "oauth", + notes: [ + "Official hosted MCP available via Todoist AI MCP docs.", + "Mika runtime currently uses native provider bridge tools for headless execution.", + ], + }; + case "calcom": + return { + server_url: "https://mcp.cal.com/mcp", + transport: "streamable-http", + auth_mode: "oauth", + notes: [ + "Official hosted MCP available via interactive OAuth.", + "Mika runtime currently uses native provider bridge tools for headless execution.", + ], + }; + default: + return { + server_url: null, + transport: null, + auth_mode: "unknown", + notes: ["No official remote MCP endpoint mapped in Mika runtime yet."], + }; + } +} + +// deno-lint-ignore no-explicit-any +async function loadAgentRecord(supabase: any, agentInstanceId: string): Promise { + const { data: agent, error: agentErr } = await supabase + .from("agent_instances") + .select("id, user_id, railway_service_id, vps_pool_id") + .eq("id", agentInstanceId) + .maybeSingle(); + + if (agentErr || !agent) { + throw new Error(`agent_instance not found: ${agentErr?.message ?? agentInstanceId}`); + } + + return agent as AgentInstanceRecord; +} + +async function loadAgentServiceTarget( + // deno-lint-ignore no-explicit-any + supabase: any, + agentInstanceId: string, + railwayToken: string, +): Promise<{ + agent: AgentInstanceRecord; + serviceId: string; + projectId: string; + environmentId: string; +}> { + const agent = await loadAgentRecord(supabase, agentInstanceId); + + if (!agent.railway_service_id) { + throw new Error("agent has no railway_service_id"); + } + + let projectId: string | null = null; + let environmentId: string | null = null; + + if (agent.vps_pool_id) { + const { data: pool, error: poolErr } = await supabase + .from("vps_pool") + .select("railway_project_id, railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + if (poolErr) { + throw new Error(`failed to load vps_pool for agent ${agent.id}: ${poolErr.message}`); + } + projectId = pool?.railway_project_id ?? null; + environmentId = pool?.railway_environment_id ?? null; + } + + if (!projectId || !environmentId) { + const ctx = await getServiceContext({ + token: railwayToken, + serviceId: agent.railway_service_id, + }); + projectId = projectId ?? ctx.projectId; + environmentId = environmentId ?? ctx.environmentId; + } + + if (!projectId || !environmentId) { + throw new Error("failed to resolve railway project/environment for agent"); + } + + return { + agent, + serviceId: agent.railway_service_id, + projectId, + environmentId, + }; +} + +async function resolveRuntimeTarget(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; +}): Promise { + const target = await loadAgentServiceTarget(opts.supabase, opts.agentInstanceId, opts.railwayToken); + const publicDomain = await ensureRailwayServiceDomain({ + token: opts.railwayToken, + serviceId: target.serviceId, + environmentId: target.environmentId, + projectId: target.projectId, + }); + + return { + serviceId: target.serviceId, + projectId: target.projectId, + environmentId: target.environmentId, + publicDomain: publicDomain.domain, + publicUrl: `https://${publicDomain.domain}`, + }; +} + +// deno-lint-ignore no-explicit-any +async function vaultDecryptSecret(supabase: any, secretId: string | null): Promise { + if (!secretId) return null; + + const { data, error } = await supabase + .rpc("vault_decrypt_secret", { secret_id: secretId }) + .single(); + + if (error || !data) { + return null; + } + + if (typeof data === "string") { + return data; + } + + if (Array.isArray(data) && data.length > 0 && typeof data[0]?.decrypted_secret === "string") { + return data[0].decrypted_secret; + } + + if (typeof (data as { decrypted_secret?: unknown }).decrypted_secret === "string") { + return (data as { decrypted_secret: string }).decrypted_secret; + } + + return null; +} + +async function postPayloadToAgent(opts: { + publicUrl: string; + apiKey: string; + path: string; + payload: unknown; + maxAttempts?: number; +}): Promise { + const maxAttempts = opts.maxAttempts ?? 4; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch(`${opts.publicUrl.replace(/\/$/, "")}${opts.path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${opts.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(opts.payload), + }); + + if (res.ok) { + return await res.json().catch(() => ({})); + } + + const detail = await res.text().catch(() => ""); + const shouldRetry = ![400, 401, 403].includes(res.status); + lastError = new Error(`${opts.path} HTTP ${res.status}: ${detail}`); + if (!shouldRetry || attempt === maxAttempts) { + throw lastError; + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt === maxAttempts) { + throw lastError; + } + } + + await sleep(attempt * 1000); + } + + throw lastError ?? new Error(`${opts.path} sync failed`); +} + +async function getPayloadFromAgent(opts: { + publicUrl: string; + apiKey: string; + path: string; + maxAttempts?: number; +}): Promise { + const maxAttempts = opts.maxAttempts ?? 4; + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch(`${opts.publicUrl.replace(/\/$/, "")}${opts.path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${opts.apiKey}`, + }, + }); + + if (res.ok) { + return await res.json().catch(() => ({})); + } + + const detail = await res.text().catch(() => ""); + const shouldRetry = ![400, 401, 403].includes(res.status); + lastError = new Error(`${opts.path} HTTP ${res.status}: ${detail}`); + if (!shouldRetry || attempt === maxAttempts) { + throw lastError; + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt === maxAttempts) { + throw lastError; + } + } + + await sleep(attempt * 1000); + } + + throw lastError ?? new Error(`${opts.path} fetch failed`); +} + +async function pullCronjobsRuntimeStateFromTarget(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + publicUrl: string; + publicDomain: string; + apiKey: string; +}): Promise { + const response = await getPayloadFromAgent({ + publicUrl: opts.publicUrl, + apiKey: opts.apiKey, + path: "/api/cronjobs", + }); + + const runtimeCronjobs = Array.isArray((response as RuntimeCronjobsListResponse)?.cronjobs) + ? ((response as RuntimeCronjobsListResponse).cronjobs ?? []) + : []; + + const syncedAt = new Date().toISOString(); + let updatedCount = 0; + + for (const runtimeJob of runtimeCronjobs) { + const jobId = asNullableString(runtimeJob?.id); + if (!jobId) continue; + + const { error } = await opts.supabase + .from("scheduled_jobs") + .update({ + last_run_at: asNullableString(runtimeJob.last_run_at), + next_run_at: asNullableString(runtimeJob.next_run_at), + runtime_state: normalizeRuntimeState(runtimeJob.state), + runtime_last_status: normalizeRuntimeLastStatus(runtimeJob.last_status), + runtime_last_error: asNullableString(runtimeJob.last_error), + runtime_last_delivery_error: asNullableString(runtimeJob.last_delivery_error), + runtime_synced_at: syncedAt, + }) + .eq("id", jobId) + .eq("agent_instance_id", opts.agentInstanceId); + + if (error) { + throw new Error(`failed to reconcile runtime cronjob ${jobId}: ${error.message}`); + } + + updatedCount += 1; + } + + await opts.supabase + .from("agent_instances") + .update({ last_health_check_at: syncedAt }) + .eq("id", opts.agentInstanceId); + + return { + agent_instance_id: opts.agentInstanceId, + public_url: opts.publicUrl, + public_domain: opts.publicDomain, + synced_count: runtimeCronjobs.length, + updated_count: updatedCount, + response, + }; +} + +export async function buildActiveSkillsPayload( + // deno-lint-ignore no-explicit-any + supabase: any, + agentInstanceId: string, +): Promise { + const { data: skillsData, error: skillsErr } = await supabase + .from("skills") + .select("id, agent_instance_id, name, description, trigger_keywords, current_version_id, updated_at") + .eq("agent_instance_id", agentInstanceId) + .eq("status", "active") + .not("current_version_id", "is", null) + .order("updated_at", { ascending: false }); + + if (skillsErr) { + throw new Error(`failed to load skills: ${skillsErr.message}`); + } + + const skills = (skillsData ?? []) as ActiveSkillRow[]; + const versionIds = Array.from( + new Set(skills.map((skill) => skill.current_version_id).filter(Boolean)), + ); + + let versionsById = new Map(); + if (versionIds.length > 0) { + const { data: versionsData, error: versionsErr } = await supabase + .from("skill_versions") + .select("id, version_number, markdown_content, form_inputs, created_at") + .in("id", versionIds); + + if (versionsErr) { + throw new Error(`failed to load skill_versions: ${versionsErr.message}`); + } + + versionsById = new Map( + ((versionsData ?? []) as SkillVersionRow[]).map((version) => [version.id, version]), + ); + } + + const payloadSkills = skills.flatMap((skill) => { + const version = versionsById.get(skill.current_version_id); + if (!version) return []; + + return [{ + skill_id: skill.id, + version_id: version.id, + version_number: version.version_number, + name: skill.name, + description: skill.description, + trigger_keywords: skill.trigger_keywords, + updated_at: skill.updated_at, + created_at: version.created_at, + form_inputs: version.form_inputs ?? null, + markdown_content: ensureSkillFrontmatter({ + name: skill.name, + description: skill.description, + trigger_keywords: skill.trigger_keywords, + markdown_content: version.markdown_content, + }), + }]; + }); + + return { + agent_instance_id: agentInstanceId, + synced_at: new Date().toISOString(), + skills: payloadSkills, + }; +} + +export async function pushSkillsPayloadToAgent(opts: { + publicUrl: string; + apiKey: string; + payload: AgentSkillsSyncPayload; + maxAttempts?: number; +}): Promise { + return await postPayloadToAgent({ + publicUrl: opts.publicUrl, + apiKey: opts.apiKey, + path: "/api/skills/sync", + payload: opts.payload, + maxAttempts: opts.maxAttempts, + }); +} + +export async function syncAgentSkillsSnapshot(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; + apiKey: string; +}): Promise { + if (!opts.railwayToken) { + throw new Error("RAILWAY_API_TOKEN not configured"); + } + if (!opts.apiKey) { + throw new Error("HERMES_API_SERVER_KEY not configured"); + } + + const payload = await buildActiveSkillsPayload(opts.supabase, opts.agentInstanceId); + const target = await resolveRuntimeTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + railwayToken: opts.railwayToken, + }); + const response = await pushSkillsPayloadToAgent({ + publicUrl: target.publicUrl, + apiKey: opts.apiKey, + payload, + }); + + return { + agent_instance_id: opts.agentInstanceId, + public_url: target.publicUrl, + public_domain: target.publicDomain, + synced_count: payload.skills.length, + response, + }; +} + +export async function buildManagedCronjobsPayload( + // deno-lint-ignore no-explicit-any + supabase: any, + agentInstanceId: string, +): Promise { + const { data, error } = await supabase + .from("scheduled_jobs") + .select( + "id, agent_instance_id, name, description, natural_language_input, cron_expression, human_readable, action_prompt, required_mcp_slugs, status, auto_paused_reason, last_run_at, next_run_at, timezone, created_at, updated_at", + ) + .eq("agent_instance_id", agentInstanceId) + .neq("status", "archived") + .order("created_at", { ascending: true }); + + if (error) { + throw new Error(`failed to load scheduled_jobs: ${error.message}`); + } + + const rows = (data ?? []) as ScheduledJobRow[]; + + return { + agent_instance_id: agentInstanceId, + synced_at: new Date().toISOString(), + cronjobs: rows.map((job) => ({ + job_id: job.id, + name: job.name, + description: job.description ?? null, + natural_language_input: job.natural_language_input, + cron_expression: job.cron_expression, + human_readable: job.human_readable, + action_prompt: job.action_prompt, + required_mcp_slugs: ensureStringArray(job.required_mcp_slugs), + status: job.status, + auto_paused_reason: job.auto_paused_reason ?? null, + last_run_at: job.last_run_at ?? null, + next_run_at: job.next_run_at ?? null, + timezone: job.timezone, + created_at: job.created_at, + updated_at: job.updated_at, + })), + }; +} + +export async function pushCronjobsPayloadToAgent(opts: { + publicUrl: string; + apiKey: string; + payload: AgentCronjobsSyncPayload; + maxAttempts?: number; +}): Promise { + return await postPayloadToAgent({ + publicUrl: opts.publicUrl, + apiKey: opts.apiKey, + path: "/api/cronjobs/sync", + payload: opts.payload, + maxAttempts: opts.maxAttempts, + }); +} + +export async function syncAgentCronjobsSnapshot(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; + apiKey: string; +}): Promise { + if (!opts.railwayToken) { + throw new Error("RAILWAY_API_TOKEN not configured"); + } + if (!opts.apiKey) { + throw new Error("HERMES_API_SERVER_KEY not configured"); + } + + const payload = await buildManagedCronjobsPayload(opts.supabase, opts.agentInstanceId); + const target = await resolveRuntimeTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + railwayToken: opts.railwayToken, + }); + const response = await pushCronjobsPayloadToAgent({ + publicUrl: target.publicUrl, + apiKey: opts.apiKey, + payload, + }); + const pullResult = await pullCronjobsRuntimeStateFromTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + publicUrl: target.publicUrl, + publicDomain: target.publicDomain, + apiKey: opts.apiKey, + }); + + return { + agent_instance_id: opts.agentInstanceId, + public_url: target.publicUrl, + public_domain: target.publicDomain, + synced_count: payload.cronjobs.length, + response: { + push: response, + pull: pullResult.response, + updated_count: pullResult.updated_count, + }, + }; +} + +export async function pullAgentCronjobsRuntimeState(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; + apiKey: string; +}): Promise { + if (!opts.railwayToken) { + throw new Error("RAILWAY_API_TOKEN not configured"); + } + if (!opts.apiKey) { + throw new Error("HERMES_API_SERVER_KEY not configured"); + } + + const target = await resolveRuntimeTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + railwayToken: opts.railwayToken, + }); + + return await pullCronjobsRuntimeStateFromTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + publicUrl: target.publicUrl, + publicDomain: target.publicDomain, + apiKey: opts.apiKey, + }); +} + +export async function buildManagedIntegrationsPayload( + // deno-lint-ignore no-explicit-any + supabase: any, + agentInstanceId: string, +): Promise { + const agent = await loadAgentRecord(supabase, agentInstanceId); + + const { data, error } = await supabase + .from("user_integrations") + .select( + "id, user_id, mcp_id, status, connected_account_email, connected_account_name, granted_scopes, token_expires_at, last_refreshed_at, error_message, access_token_vault_id, refresh_token_vault_id, created_at, updated_at, mcp:available_mcps(id, slug, name, provider, description, oauth_authorize_url, oauth_token_url, oauth_revoke_url, required_scopes, supports_refresh_token)", + ) + .eq("user_id", agent.user_id) + .order("created_at", { ascending: true }); + + if (error) { + throw new Error(`failed to load user_integrations: ${error.message}`); + } + + const rows = (data ?? []) as UserIntegrationRow[]; + const integrations = []; + + for (const row of rows) { + const mcp = Array.isArray(row.mcp) ? row.mcp[0] : row.mcp; + if (!mcp) continue; + + const accessToken = row.status === "active" + ? await vaultDecryptSecret(supabase, row.access_token_vault_id) + : null; + const refreshToken = row.status === "active" + ? await vaultDecryptSecret(supabase, row.refresh_token_vault_id) + : null; + + integrations.push({ + integration_id: row.id, + mcp_id: row.mcp_id, + slug: mcp.slug, + name: mcp.name, + provider: mcp.provider, + description: mcp.description, + status: row.status, + connected_account_email: row.connected_account_email ?? null, + connected_account_name: row.connected_account_name ?? null, + granted_scopes: ensureStringArray(row.granted_scopes), + required_scopes: ensureStringArray(mcp.required_scopes), + token_expires_at: row.token_expires_at ?? null, + last_refreshed_at: row.last_refreshed_at ?? null, + error_message: row.error_message ?? null, + oauth_authorize_url: mcp.oauth_authorize_url, + oauth_token_url: mcp.oauth_token_url, + oauth_revoke_url: mcp.oauth_revoke_url ?? null, + supports_refresh_token: !!mcp.supports_refresh_token, + access_token: accessToken, + refresh_token: refreshToken, + runtime_mcp: getOfficialRuntimeMcpHint(mcp.slug), + created_at: row.created_at, + updated_at: row.updated_at, + }); + } + + return { + agent_instance_id: agentInstanceId, + user_id: agent.user_id, + synced_at: new Date().toISOString(), + integrations, + }; +} + +export async function pushIntegrationsPayloadToAgent(opts: { + publicUrl: string; + apiKey: string; + payload: AgentIntegrationsSyncPayload; + maxAttempts?: number; +}): Promise { + return await postPayloadToAgent({ + publicUrl: opts.publicUrl, + apiKey: opts.apiKey, + path: "/api/integrations/sync", + payload: opts.payload, + maxAttempts: opts.maxAttempts, + }); +} + +export async function syncAgentIntegrationsSnapshot(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; + apiKey: string; +}): Promise { + if (!opts.railwayToken) { + throw new Error("RAILWAY_API_TOKEN not configured"); + } + if (!opts.apiKey) { + throw new Error("HERMES_API_SERVER_KEY not configured"); + } + + const payload = await buildManagedIntegrationsPayload(opts.supabase, opts.agentInstanceId); + const target = await resolveRuntimeTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + railwayToken: opts.railwayToken, + }); + const response = await pushIntegrationsPayloadToAgent({ + publicUrl: target.publicUrl, + apiKey: opts.apiKey, + payload, + }); + + return { + agent_instance_id: opts.agentInstanceId, + public_url: target.publicUrl, + public_domain: target.publicDomain, + synced_count: payload.integrations.length, + response, + }; +} + +export async function syncAgentRuntimeSnapshot(opts: { + // deno-lint-ignore no-explicit-any + supabase: any; + agentInstanceId: string; + railwayToken: string; + apiKey: string; + scope?: RuntimeSyncScope; +}): Promise { + if (!opts.railwayToken) { + throw new Error("RAILWAY_API_TOKEN not configured"); + } + if (!opts.apiKey) { + throw new Error("HERMES_API_SERVER_KEY not configured"); + } + + const scope = opts.scope ?? "all"; + const target = await resolveRuntimeTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + railwayToken: opts.railwayToken, + }); + + let cronjobsSyncedCount = 0; + let integrationsSyncedCount = 0; + let cronjobsResponse: unknown = { skipped: true }; + let integrationsResponse: unknown = { skipped: true }; + + if (scope === "cronjobs" || scope === "all") { + const cronjobsPayload = await buildManagedCronjobsPayload(opts.supabase, opts.agentInstanceId); + const cronjobsPushResponse = await pushCronjobsPayloadToAgent({ + publicUrl: target.publicUrl, + apiKey: opts.apiKey, + payload: cronjobsPayload, + }); + const cronjobsPullResult = await pullCronjobsRuntimeStateFromTarget({ + supabase: opts.supabase, + agentInstanceId: opts.agentInstanceId, + publicUrl: target.publicUrl, + publicDomain: target.publicDomain, + apiKey: opts.apiKey, + }); + cronjobsResponse = { + push: cronjobsPushResponse, + pull: cronjobsPullResult.response, + updated_count: cronjobsPullResult.updated_count, + }; + cronjobsSyncedCount = cronjobsPayload.cronjobs.length; + } + + if (scope === "integrations" || scope === "all") { + const integrationsPayload = await buildManagedIntegrationsPayload(opts.supabase, opts.agentInstanceId); + integrationsResponse = await pushIntegrationsPayloadToAgent({ + publicUrl: target.publicUrl, + apiKey: opts.apiKey, + payload: integrationsPayload, + }); + integrationsSyncedCount = integrationsPayload.integrations.length; + } + + return { + agent_instance_id: opts.agentInstanceId, + public_url: target.publicUrl, + public_domain: target.publicDomain, + cronjobs_synced_count: cronjobsSyncedCount, + integrations_synced_count: integrationsSyncedCount, + responses: { + cronjobs: cronjobsResponse, + integrations: integrationsResponse, + }, + }; +} diff --git a/supabase/functions/disconnect-integration/index.ts b/supabase/functions/disconnect-integration/index.ts index 4a940c8..7080753 100644 --- a/supabase/functions/disconnect-integration/index.ts +++ b/supabase/functions/disconnect-integration/index.ts @@ -11,6 +11,10 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { corsHeaders } from "../_shared/cors.ts"; import { revokeToken, type ProviderSlug } from "../_shared/oauth-providers.ts"; +import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; + +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -164,8 +168,36 @@ Deno.serve(async (req) => { return jsonResponse({ error: "Falha ao remover integração" }, 500); } - // TODO Fase 5: notify Hermes container that MCP was disconnected - return jsonResponse({ success: true, paused_jobs_count: pausedCount }); + const { data: agent } = await admin + .from("agent_instances") + .select("id") + .eq("user_id", userId) + .maybeSingle(); + + let runtimeSyncError: string | null = null; + if (agent?.id) { + try { + await syncAgentRuntimeSnapshot({ + supabase: admin, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + scope: "all", + }); + } catch (syncErr) { + runtimeSyncError = syncErr instanceof Error ? syncErr.message : "unknown"; + console.error( + "disconnect-integration runtime sync warning", + runtimeSyncError, + ); + } + } + + return jsonResponse({ + success: true, + paused_jobs_count: pausedCount, + runtime_sync_warning: runtimeSyncError, + }); } catch (err) { console.error("disconnect-integration fatal", err instanceof Error ? err.message : "unknown"); return jsonResponse( diff --git a/supabase/functions/keep-alive-agents/index.ts b/supabase/functions/keep-alive-agents/index.ts index f306675..90aa1db 100644 --- a/supabase/functions/keep-alive-agents/index.ts +++ b/supabase/functions/keep-alive-agents/index.ts @@ -1,7 +1,10 @@ // keep-alive-agents -// Mantém todos os containers Railway dos agentes ativos "acordados" fazendo -// uma request GET /getMe ao Telegram para cada agente. Isso força tráfego de -// saída no container, evitando que o Railway hiberne instâncias ociosas. +// Mantém todos os containers Railway dos agentes ativos "acordados" e aproveita +// o ciclo para reconciliar o estado operacional dos cronjobs de volta no banco. +// +// Estratégia: +// 1. Faz GET /getMe no Telegram quando o agente já tem bot configurado +// 2. Puxa /api/cronjobs do runtime Hermes e atualiza scheduled_jobs // // Substitui completamente o UptimeRobot — não precisa de configuração externa // por agente. Roda via pg_cron a cada 4 minutos. @@ -10,14 +13,18 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { corsHeaders } from "../_shared/cors.ts"; +import { pullAgentCronjobsRuntimeState } from "../_shared/runtime-sync.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; interface AgentRow { id: string; user_id: string; - telegram_bot_token_vault_id: string; + railway_service_id: string | null; + telegram_bot_token_vault_id: string | null; telegram_bot_username: string | null; } @@ -28,12 +35,11 @@ Deno.serve(async (req) => { auth: { persistSession: false, autoRefreshToken: false }, }); - // 1) Buscar agentes ativos com token configurado + // 1) Buscar agentes ativos; Telegram e runtime são tratados separadamente const { data: agents, error: agentsErr } = await supabase .from("agent_instances") - .select("id, user_id, telegram_bot_token_vault_id, telegram_bot_username") - .eq("status", "active") - .not("telegram_bot_token_vault_id", "is", null); + .select("id, user_id, railway_service_id, telegram_bot_token_vault_id, telegram_bot_username") + .eq("status", "active"); if (agentsErr) { console.error("keep-alive: failed to load agents:", agentsErr.message); @@ -41,43 +47,78 @@ Deno.serve(async (req) => { } const list = (agents ?? []) as AgentRow[]; - let success = 0; - let failed = 0; + let telegramSuccess = 0; + let telegramFailed = 0; + let telegramSkipped = 0; + let runtimeSyncSuccess = 0; + let runtimeSyncFailed = 0; + let runtimeSyncSkipped = 0; + const runtimeSyncEnabled = Boolean(RAILWAY_API_TOKEN && HERMES_API_SERVER_KEY); - // 2) Para cada agente, decrypt token + GET /getMe (em paralelo, mas sem quebrar o loop) + // 2) Para cada agente, ping no Telegram + reconciliação de runtime await Promise.all( list.map(async (agent) => { + if (agent.telegram_bot_token_vault_id) { + try { + const { data: secret, error: secretErr } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + + if (secretErr || !secret?.[0]?.decrypted_secret) { + console.warn(`keep-alive: missing token for agent ${agent.id} (${agent.telegram_bot_username ?? "?"})`); + telegramFailed++; + } else { + const token = secret[0].decrypted_secret as string; + const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { method: "GET" }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + console.warn(`keep-alive: getMe failed for agent ${agent.id} (${agent.telegram_bot_username ?? "?"}): ${res.status} ${text.slice(0, 200)}`); + telegramFailed++; + } else { + telegramSuccess++; + } + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn(`keep-alive: telegram exception for agent ${agent.id}: ${msg}`); + telegramFailed++; + } + } else { + telegramSkipped++; + } + + if (!runtimeSyncEnabled || !agent.railway_service_id) { + runtimeSyncSkipped++; + return; + } + try { - const { data: secret, error: secretErr } = await supabase.rpc("vault_decrypt_secret", { - secret_id: agent.telegram_bot_token_vault_id, + await pullAgentCronjobsRuntimeState({ + supabase, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, }); - - if (secretErr || !secret?.[0]?.decrypted_secret) { - console.warn(`keep-alive: missing token for agent ${agent.id} (${agent.telegram_bot_username ?? "?"})`); - failed++; - return; - } - - const token = secret[0].decrypted_secret as string; - const res = await fetch(`https://api.telegram.org/bot${token}/getMe`, { method: "GET" }); - - if (!res.ok) { - const text = await res.text().catch(() => ""); - console.warn(`keep-alive: getMe failed for agent ${agent.id} (${agent.telegram_bot_username ?? "?"}): ${res.status} ${text.slice(0, 200)}`); - failed++; - return; - } - - success++; + runtimeSyncSuccess++; } catch (e) { const msg = e instanceof Error ? e.message : String(e); - console.warn(`keep-alive: exception for agent ${agent.id}: ${msg}`); - failed++; + console.warn(`keep-alive: runtime sync exception for agent ${agent.id}: ${msg}`); + runtimeSyncFailed++; } }), ); - const summary = { total: list.length, success, failed }; + const summary = { + total: list.length, + telegram_success: telegramSuccess, + telegram_failed: telegramFailed, + telegram_skipped: telegramSkipped, + runtime_sync_enabled: runtimeSyncEnabled, + runtime_sync_success: runtimeSyncSuccess, + runtime_sync_failed: runtimeSyncFailed, + runtime_sync_skipped: runtimeSyncSkipped, + }; console.log("keep-alive summary:", JSON.stringify(summary)); return jsonResponse(200, summary); }); diff --git a/supabase/functions/list-ollama-models/index.ts b/supabase/functions/list-ollama-models/index.ts new file mode 100644 index 0000000..486d840 --- /dev/null +++ b/supabase/functions/list-ollama-models/index.ts @@ -0,0 +1,110 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { + DEFAULT_OLLAMA_MODEL, + normalizeOllamaModelSelection, +} from "../_shared/hermes-config.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const OLLAMA_API_KEY = Deno.env.get("OLLAMA_API_KEY"); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +interface OllamaTagModel { + name?: string; + model?: string; + modified_at?: string; + size?: number; + digest?: string; + details?: Record; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + try { + const authHeader = req.headers.get("Authorization") ?? ""; + const jwt = authHeader.replace(/^Bearer\s+/i, ""); + if (!jwt) return jsonResponse({ error: "missing authorization" }, 401); + + const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userData, error: userErr } = await admin.auth.getUser(jwt); + if (userErr || !userData?.user) { + return jsonResponse({ error: "invalid token" }, 401); + } + + const { data: isAdmin, error: roleErr } = await admin.rpc("has_role", { + _user_id: userData.user.id, + _role: "admin", + }); + if (roleErr || !isAdmin) { + return jsonResponse({ error: "admin role required" }, 403); + } + + if (!OLLAMA_API_KEY) { + return jsonResponse({ error: "OLLAMA_API_KEY not configured" }, 500); + } + + const res = await fetch("https://ollama.com/api/tags", { + headers: { + Authorization: `Bearer ${OLLAMA_API_KEY}`, + }, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + return jsonResponse( + { + error: `ollama tags request failed: ${res.status}`, + detail: text, + }, + 502, + ); + } + + const payload = await res.json().catch(() => ({})); + const models = Array.isArray(payload?.models) ? (payload.models as OllamaTagModel[]) : []; + + const normalized = models + .map((item) => { + const raw = item.name || item.model || ""; + const name = normalizeOllamaModelSelection(raw); + return { + name, + raw_name: raw, + modified_at: item.modified_at ?? null, + size: item.size ?? null, + digest: item.digest ?? null, + details: item.details ?? {}, + }; + }) + .filter((item) => /(?:-cloud|:cloud)$/.test(item.name)) + .sort((a, b) => { + if (a.name === DEFAULT_OLLAMA_MODEL) return -1; + if (b.name === DEFAULT_OLLAMA_MODEL) return 1; + return a.name.localeCompare(b.name); + }); + + return jsonResponse({ + models: normalized, + default_model: DEFAULT_OLLAMA_MODEL, + source_endpoint: "https://ollama.com/api/tags", + }); + } catch (err) { + return jsonResponse( + { error: err instanceof Error ? err.message : "unexpected error" }, + 500, + ); + } +}); diff --git a/supabase/functions/oauth-callback/index.ts b/supabase/functions/oauth-callback/index.ts index 17d0d2d..352150c 100644 --- a/supabase/functions/oauth-callback/index.ts +++ b/supabase/functions/oauth-callback/index.ts @@ -9,6 +9,10 @@ import { getProviderEnv, type ProviderSlug, } from "../_shared/oauth-providers.ts"; +import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; + +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; function siteUrl(): string { return Deno.env.get("SITE_URL") ?? "https://798b89e5-0dc6-412a-81be-a4b6dfea7b6c.lovable.app"; @@ -175,7 +179,29 @@ Deno.serve(async (req) => { return redirect("/painel/integracoes?error=db_error"); } - // TODO Fase 5: notify Hermes container that MCP is now available + const { data: agent } = await admin + .from("agent_instances") + .select("id") + .eq("user_id", stateRow.user_id) + .maybeSingle(); + + if (agent?.id) { + try { + await syncAgentRuntimeSnapshot({ + supabase: admin, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + scope: "all", + }); + } catch (syncErr) { + console.error( + "oauth-callback runtime sync warning", + syncErr instanceof Error ? syncErr.message : String(syncErr), + ); + } + } + return redirect(`/painel/integracoes?status=success&mcp=${encodeURIComponent(slug)}`); } catch (err) { console.error("oauth-callback fatal", err instanceof Error ? err.message : "unknown"); diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index 6fb762c..1ac44c3 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -14,6 +14,11 @@ import { findRailwayServiceByName, upsertRailwayVariableCollection, } from "../_shared/railway.ts"; +import { + DEFAULT_OLLAMA_MODEL, + DEFAULT_OLLAMA_PROVIDER, + normalizeOllamaModelSelection, +} from "../_shared/hermes-config.ts"; interface RequestBody { agent_instance_id: string; @@ -128,8 +133,7 @@ Deno.serve(async (req) => { // deno-lint-ignore no-explicit-any const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic"; - const isPro = ["professional", "enterprise"].includes(planSlug); - console.log(`[provision-agent] plano=${planSlug} isPro=${isPro}`); + console.log(`[provision-agent] plano=${planSlug}`); // 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade) const { data: pool, error: poolErr } = await supabase @@ -215,11 +219,15 @@ Deno.serve(async (req) => { const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DomCo. exclusivamente para ${fullName}. Seu estilo: Direto e objetivo, sempre em português brasileiro, respostas curtas no Telegram, use emojis com moderação, trate ${firstName} pelo primeiro nome. Suas prioridades: produtividade, automação proativa. Identidade: você é ${agentName} da DomCo., nunca se identifique como Hermes ou qualquer outro modelo.`; const soulContent = body.soul_content?.trim() || defaultSoul; + const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); + const envVars: Record = { HERMES_HOME: "/opt/data/.hermes", API_SERVER_ENABLED: "true", API_SERVER_KEY: Deno.env.get("HERMES_API_SERVER_KEY") ?? "", GATEWAY_ALLOW_ALL_USERS: "false", + HERMES_MODEL_DEFAULT: modelFinal, + HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_SOUL_OVERRIDE: soulContent, HERMES_STT_PROVIDER: sttProvider, HERMES_TTS_PROVIDER: ttsProvider, @@ -230,10 +238,7 @@ Deno.serve(async (req) => { TELEGRAM_HOME_CHANNEL: chatIdStr, }; - // Modelo é definido pelo config.yaml embutido na imagem custom (ollama-cloud + gemma4:31b-cloud). - // NÃO injetar HERMES_MODEL como env var — sobrescreve o config.yaml e quebra o bot. const agentNameFinal = agentName; - const modelFinal = isPro ? "ollama-cloud/gemma4:31b-cloud" : "ollama-cloud/gemma4:31b-cloud"; // 7) Criar serviço no Railway const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`; @@ -307,7 +312,8 @@ Deno.serve(async (req) => { vps_pool_id: pool.id, agent_name: agentNameFinal, model_config: { - provider: modelFinal, + provider: DEFAULT_OLLAMA_PROVIDER, + model: modelFinal, stt: sttProvider, tts: ttsProvider, agent_name: agentNameFinal, @@ -431,15 +437,10 @@ async function handleUpdateExistingService( // deno-lint-ignore no-explicit-any const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic"; - const isPro = ["professional", "enterprise"].includes(planSlug); - const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`; const soulContent = body.soul_content?.trim() || defaultSoul; - const defaultModel = isPro - ? "openrouter/google/gemma-4-31b-it" - : "openrouter/google/gemma-4-27b-a4b-it"; - const model = body.model || defaultModel; + const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); const sttProvider = body.stt_provider || "local"; const ttsProvider = body.tts_provider || "disabled"; @@ -470,10 +471,9 @@ async function handleUpdateExistingService( return jsonResponse(500, { error: "failed to resolve railway project/environment" }); } - // Upsert das vars principais (não mexemos em token Telegram aqui — preservado) - // NÃO injetar HERMES_MODEL: a imagem custom já tem config.yaml com ollama-cloud/gemma4:31b-cloud. - // Sobrescrever via env var quebra o bot (model: "" / 404 not found). const variables: Record = { + HERMES_MODEL_DEFAULT: model, + HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_SOUL_OVERRIDE: soulContent, HERMES_STT_PROVIDER: sttProvider, HERMES_TTS_PROVIDER: ttsProvider, @@ -515,7 +515,8 @@ async function handleUpdateExistingService( .from("agent_instances") .update({ model_config: { - provider: model, + provider: DEFAULT_OLLAMA_PROVIDER, + model, stt: sttProvider, tts: ttsProvider, agent_name: agentName, diff --git a/supabase/functions/publish-skill-version/index.ts b/supabase/functions/publish-skill-version/index.ts index b7064a9..4edbb4c 100644 --- a/supabase/functions/publish-skill-version/index.ts +++ b/supabase/functions/publish-skill-version/index.ts @@ -1,11 +1,13 @@ // Promove uma skill_version a "live" de forma atômica e idempotente. // Garantia adicional: unique index parcial skill_versions_one_live_per_skill no banco. -// TODO Fase 5: dispatch SSH deploy to container after publish import { createClient } from "npm:@supabase/supabase-js@2"; import { corsHeaders } from "../_shared/cors.ts"; +import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); @@ -53,7 +55,7 @@ Deno.serve(async (req) => { // Carrega versão + skill (verifica ownership e estado atual) const { data: versionRow, error: vErr } = await admin .from("skill_versions") - .select("id, skill_id, version_number, is_live, skills!inner(id, user_id)") + .select("id, skill_id, version_number, is_live, skills!inner(id, user_id, agent_instance_id)") .eq("id", skill_version_id) .maybeSingle(); @@ -81,6 +83,8 @@ Deno.serve(async (req) => { } const skillId: string = versionRow.skill_id; + // @ts-expect-error nested + const agentInstanceId: string = versionRow.skills.agent_instance_id; // Postgres não permite transação multi-statement via supabase-js. // Estratégia: 1) zera todos is_live da skill, 2) marca a alvo como live, 3) atualiza skills. @@ -141,8 +145,35 @@ Deno.serve(async (req) => { }); } + let syncResult: + | { synced: true; public_url: string; public_domain: string; synced_count: number } + | { synced: false; sync_error: string } = { synced: true, public_url: "", public_domain: "", synced_count: 0 }; + + try { + const result = await syncAgentSkillsSnapshot({ + supabase: admin, + agentInstanceId, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + }); + syncResult = { + synced: true, + public_url: result.public_url, + public_domain: result.public_domain, + synced_count: result.synced_count, + }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("publish skill sync failed:", msg); + syncResult = { synced: false, sync_error: msg }; + } + return new Response( - JSON.stringify({ success: true, version_number: versionRow.version_number }), + JSON.stringify({ + success: true, + version_number: versionRow.version_number, + ...syncResult, + }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); }); diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts index 416a91e..4bd1af2 100644 --- a/supabase/functions/railway-webhook/index.ts +++ b/supabase/functions/railway-webhook/index.ts @@ -10,11 +10,17 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { corsHeaders } from "../_shared/cors.ts"; +import { + syncAgentRuntimeSnapshot, + syncAgentSkillsSnapshot, +} from "../_shared/runtime-sync.ts"; const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN"); const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID"); +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; async function notifyAdmin(message: string): Promise { if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; @@ -138,8 +144,61 @@ Deno.serve(async (req) => { } } + let runtimeSyncError: string | null = null; + try { + const runtimeResult = await syncAgentRuntimeSnapshot({ + supabase, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + scope: "all", + }); + console.log( + `railway-webhook: runtime sincronizado para agent ${agent.id} (${runtimeResult.cronjobs_synced_count} cronjobs, ${runtimeResult.integrations_synced_count} integrations)`, + ); + } catch (e) { + runtimeSyncError = e instanceof Error ? e.message : String(e); + console.error(`railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`, runtimeSyncError); + if (wasProvisioning) { + const fullName = await loadFullName(); + await notifyAdmin( + `⚠️ Agente subiu, mas o sync operacional falhou\n\n` + + `👤 Cliente: ${fullName}\n` + + `🚀 Railway: ${agent.railway_service_id}\n` + + `❗ Erro: ${runtimeSyncError}\n\n` + + `➡️ Revisar no admin`, + ); + } + } + + let skillsSyncError: string | null = null; + try { + const syncResult = await syncAgentSkillsSnapshot({ + supabase, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + }); + console.log( + `railway-webhook: skills sincronizadas para agent ${agent.id} (${syncResult.synced_count} skills)`, + ); + } catch (e) { + skillsSyncError = e instanceof Error ? e.message : String(e); + console.error(`railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`, skillsSyncError); + if (wasProvisioning) { + const fullName = await loadFullName(); + await notifyAdmin( + `⚠️ Agente subiu, mas o sync de skills falhou\n\n` + + `👤 Cliente: ${fullName}\n` + + `🚀 Railway: ${agent.railway_service_id}\n` + + `❗ Erro: ${skillsSyncError}\n\n` + + `➡️ Revisar no admin`, + ); + } + } + // Notifica admin somente se era um auto-provisionamento (status anterior=provisioning) - if (wasProvisioning) { + if (wasProvisioning && !skillsSyncError && !runtimeSyncError) { const fullName = await loadFullName(); await notifyAdmin( `✅ Agente provisionado automaticamente!\n\n` + diff --git a/supabase/functions/refresh-integration-token/index.ts b/supabase/functions/refresh-integration-token/index.ts index 1de3936..8f19fb8 100644 --- a/supabase/functions/refresh-integration-token/index.ts +++ b/supabase/functions/refresh-integration-token/index.ts @@ -8,6 +8,10 @@ import { type ProviderSlug, refreshAccessToken, } from "../_shared/oauth-providers.ts"; +import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; + +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -174,7 +178,33 @@ Deno.serve(async (req) => { }) .eq("id", integration_id); - return jsonResponse({ success: true, expires_at: expiresAt }); + let runtimeSyncError: string | null = null; + const { data: agent } = await admin + .from("agent_instances") + .select("id") + .eq("user_id", userId) + .maybeSingle(); + + if (agent?.id) { + try { + await syncAgentRuntimeSnapshot({ + supabase: admin, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + scope: "integrations", + }); + } catch (syncErr) { + runtimeSyncError = syncErr instanceof Error ? syncErr.message : String(syncErr); + console.error("refresh-integration-token runtime sync warning", runtimeSyncError); + } + } + + return jsonResponse({ + success: true, + expires_at: expiresAt, + runtime_sync_warning: runtimeSyncError, + }); } catch (err) { console.error("refresh-integration-token fatal", err instanceof Error ? err.message : "unknown"); return jsonResponse( diff --git a/supabase/functions/sync-agent-runtime/index.ts b/supabase/functions/sync-agent-runtime/index.ts new file mode 100644 index 0000000..524d490 --- /dev/null +++ b/supabase/functions/sync-agent-runtime/index.ts @@ -0,0 +1,109 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; + +type RuntimeSyncScope = "cronjobs" | "integrations" | "all"; + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function isValidScope(scope: unknown): scope is RuntimeSyncScope { + return scope === "cronjobs" || scope === "integrations" || scope === "all"; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + const authHeader = req.headers.get("Authorization") ?? ""; + const jwt = authHeader.replace(/^Bearer\s+/i, ""); + if (!jwt) { + return jsonResponse(401, { error: "missing authorization" }); + } + + const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: `Bearer ${jwt}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) { + return jsonResponse(401, { error: "invalid token" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + let body: { agent_instance_id?: string; scope?: RuntimeSyncScope }; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + if (body.scope && !isValidScope(body.scope)) { + return jsonResponse(400, { error: "invalid scope" }); + } + + const { data: agent, error: agentErr } = await supabase + .from("agent_instances") + .select("id, user_id") + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (agentErr || !agent) { + return jsonResponse(404, { error: "agent_instance not found" }); + } + + const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", { + _user_id: userData.user.id, + _role: "admin", + }); + if (roleErr) { + return jsonResponse(500, { error: "failed to resolve role" }); + } + + if (agent.user_id !== userData.user.id && !isAdmin) { + return jsonResponse(403, { error: "forbidden" }); + } + + try { + const result = await syncAgentRuntimeSnapshot({ + supabase, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + scope: body.scope ?? "all", + }); + + return jsonResponse(200, { + success: true, + agent_instance_id: result.agent_instance_id, + public_url: result.public_url, + public_domain: result.public_domain, + cronjobs_synced_count: result.cronjobs_synced_count, + integrations_synced_count: result.integrations_synced_count, + runtime_responses: result.responses, + }); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + console.error("sync-agent-runtime failed:", detail); + return jsonResponse(500, { error: "runtime sync failed", detail }); + } +}); diff --git a/supabase/functions/sync-agent-skills/index.ts b/supabase/functions/sync-agent-skills/index.ts new file mode 100644 index 0000000..4bdd14e --- /dev/null +++ b/supabase/functions/sync-agent-skills/index.ts @@ -0,0 +1,97 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders }); + } + + const authHeader = req.headers.get("Authorization") ?? ""; + const jwt = authHeader.replace(/^Bearer\s+/i, ""); + if (!jwt) { + return jsonResponse(401, { error: "missing authorization" }); + } + + const userClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: `Bearer ${jwt}` } }, + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: userData, error: userErr } = await userClient.auth.getUser(); + if (userErr || !userData?.user) { + return jsonResponse(401, { error: "invalid token" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + let body: { agent_instance_id?: string }; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + const { data: agent, error: agentErr } = await supabase + .from("agent_instances") + .select("id, user_id") + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (agentErr || !agent) { + return jsonResponse(404, { error: "agent_instance not found" }); + } + + const { data: isAdmin, error: roleErr } = await supabase.rpc("has_role", { + _user_id: userData.user.id, + _role: "admin", + }); + if (roleErr) { + return jsonResponse(500, { error: "failed to resolve role" }); + } + + if (agent.user_id !== userData.user.id && !isAdmin) { + return jsonResponse(403, { error: "forbidden" }); + } + + try { + const result = await syncAgentSkillsSnapshot({ + supabase, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + }); + + return jsonResponse(200, { + success: true, + agent_instance_id: result.agent_instance_id, + public_url: result.public_url, + public_domain: result.public_domain, + synced_count: result.synced_count, + runtime_response: result.response, + }); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + console.error("sync-agent-skills failed:", detail); + return jsonResponse(500, { error: "skills sync failed", detail }); + } +}); diff --git a/supabase/functions/update-agent-config/index.ts b/supabase/functions/update-agent-config/index.ts index 2491410..fdd82a1 100644 --- a/supabase/functions/update-agent-config/index.ts +++ b/supabase/functions/update-agent-config/index.ts @@ -10,6 +10,11 @@ import { getServiceContext, upsertRailwayVariableCollection, } from "../_shared/railway.ts"; +import { + DEFAULT_OLLAMA_MODEL, + DEFAULT_OLLAMA_PROVIDER, + normalizeOllamaModelSelection, +} from "../_shared/hermes-config.ts"; interface RequestBody { agent_instance_id: string; @@ -102,7 +107,11 @@ Deno.serve(async (req) => { } // 4) Upsert variáveis (incluindo HERMES_SOUL_OVERRIDE editado pelo admin) + const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); + const variables: Record = { + HERMES_MODEL_DEFAULT: model, + HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_SOUL_OVERRIDE: body.soul_content, HERMES_STT_PROVIDER: body.stt_provider || "local", HERMES_TTS_PROVIDER: body.tts_provider || "disabled", @@ -134,7 +143,8 @@ Deno.serve(async (req) => { .from("agent_instances") .update({ model_config: { - provider: body.model, + provider: DEFAULT_OLLAMA_PROVIDER, + model, stt: body.stt_provider || "local", tts: body.tts_provider || "disabled", agent_name: body.agent_name ?? null, diff --git a/supabase/migrations/20260430121500_runtime_cronjob_observability.sql b/supabase/migrations/20260430121500_runtime_cronjob_observability.sql new file mode 100644 index 0000000..bbfa0c6 --- /dev/null +++ b/supabase/migrations/20260430121500_runtime_cronjob_observability.sql @@ -0,0 +1,25 @@ +-- ============================================================================ +-- FASE 5 — Observabilidade do runtime de cronjobs +-- ============================================================================ + +-- A UI e as Edge Functions já usam o estado "auto_paused", mas a migration +-- original ainda não o aceitava no CHECK da coluna status. +ALTER TABLE public.scheduled_jobs + DROP CONSTRAINT IF EXISTS scheduled_jobs_status_check; + +ALTER TABLE public.scheduled_jobs + ADD CONSTRAINT scheduled_jobs_status_check + CHECK (status IN ('active', 'paused', 'auto_paused', 'error', 'archived')); + +-- Espelha no Supabase o estado operacional retornado pelo runtime do Hermes. +ALTER TABLE public.scheduled_jobs + ADD COLUMN IF NOT EXISTS runtime_state text + CHECK (runtime_state IS NULL OR runtime_state IN ('scheduled', 'paused', 'completed', 'error')), + ADD COLUMN IF NOT EXISTS runtime_last_status text + CHECK (runtime_last_status IS NULL OR runtime_last_status IN ('ok', 'error')), + ADD COLUMN IF NOT EXISTS runtime_last_error text, + ADD COLUMN IF NOT EXISTS runtime_last_delivery_error text, + ADD COLUMN IF NOT EXISTS runtime_synced_at timestamptz; + +CREATE INDEX IF NOT EXISTS idx_scheduled_jobs_agent_runtime_synced_at + ON public.scheduled_jobs (agent_instance_id, runtime_synced_at DESC NULLS LAST);