Go-live runtime sync and control plane hardening (#1)

* Implement Mika runtime sync and go-live controls

* Add CI validation workflow

* Align CI with validated runtime checks

* Fix Mika CI install workflow
This commit is contained in:
Felipe Domingues 2026-04-30 20:41:06 -03:00 committed by GitHub
parent cb54fa4666
commit d87ea2657c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 4019 additions and 95 deletions

View file

@ -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) {
<h3 className="font-semibold truncate">{job.name}</h3>
{isActive && <Badge variant="success">Ativa</Badge>}
{job.status === "paused" && <Badge variant="secondary">Pausada</Badge>}
{job.status === "error" && <Badge variant="destructive">Erro</Badge>}
{isAutoPaused && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" /> Auto-pausada
</Badge>
)}
{hasRuntimeError && !isAutoPaused && (
<Badge variant="destructive">Runtime com erro</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{job.human_readable}
@ -92,6 +109,10 @@ export function CronjobCard({ job }: Props) {
<p className="text-xs text-destructive">{job.auto_paused_reason}</p>
)}
{hasRuntimeError && runtimeErrorText && (
<p className="text-xs text-destructive">{runtimeErrorText}</p>
)}
{missingMcps.length > 0 && isActive && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs">
<span className="text-amber-700 dark:text-amber-400 font-medium">

View file

@ -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";

View file

@ -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);

View file

@ -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"),
});

View file

@ -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;

View file

@ -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

35
src/lib/ollama-models.ts Normal file
View file

@ -0,0 +1,35 @@
export const DEFAULT_OLLAMA_PROVIDER = "ollama-cloud";
export const DEFAULT_OLLAMA_MODEL = "gemma4:31b-cloud";
const LEGACY_MODEL_ALIASES: Record<string, string> = {
"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;
}

View file

@ -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,
});
}

View file

@ -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<SyncAgentSkillsResponse>("sync-agent-skills", {
agent_instance_id: agentInstanceId,
});
}

View file

@ -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<string, unknown>;
}
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<string, string | undefined>;
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<string, string>();
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() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
{modelOptions.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Catálogo carregado dinamicamente do Ollama Cloud.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">

View file

@ -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() {
<div className="flex items-center gap-2 mt-2">
{isActive && <Badge variant="success">Ativa</Badge>}
{job.status === "paused" && <Badge variant="secondary">Pausada</Badge>}
{job.status === "error" && <Badge variant="destructive">Erro</Badge>}
{isAutoPaused && (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" /> Auto-pausada
</Badge>
)}
{hasRuntimeError && !isAutoPaused && (
<Badge variant="destructive">Runtime com erro</Badge>
)}
</div>
</div>
<div className="flex gap-2">
@ -138,6 +156,22 @@ function CronjobDetailPage() {
</div>
)}
{hasRuntimeError && (
<div className="rounded-md border border-destructive bg-destructive/10 p-4 text-sm">
<p className="font-medium text-destructive">Falha reportada pelo runtime</p>
<p className="text-muted-foreground mt-1">
{job.runtime_last_error ?? "O runtime marcou a última execução como erro."}
</p>
</div>
)}
{job.runtime_last_delivery_error && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
<p className="font-medium text-amber-700 dark:text-amber-400">Falha na entrega do resultado</p>
<p className="text-muted-foreground mt-1">{job.runtime_last_delivery_error}</p>
</div>
)}
{missingMcps.length > 0 && isActive && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-4 text-sm">
<p className="font-medium text-amber-700 dark:text-amber-400">
@ -182,6 +216,23 @@ function CronjobDetailPage() {
: "Nunca"
}
/>
<Section label="Estado no runtime" value={formatRuntimeState(job.runtime_state)} />
<Section
label="Último resultado do runtime"
value={formatRuntimeLastStatus(job.runtime_last_status)}
/>
<Section
label="Última sincronização do runtime"
value={
job.runtime_synced_at
? new Date(job.runtime_synced_at).toLocaleString("pt-BR", {
timeZone: job.timezone,
dateStyle: "full",
timeStyle: "short",
})
: "Ainda não sincronizado"
}
/>
<Section label="Fuso horário" value={job.timezone} />
<Section label="Descrição original (NL)" value={job.natural_language_input} />
{job.description && <Section label="Notas" value={job.description} />}
@ -265,3 +316,29 @@ function Section({
</div>
);
}
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";
}
}

View file

@ -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"] });
}

View file

@ -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"),

View file

@ -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!");
}