fix: synchronize skill deletion server-side

This commit is contained in:
Felipe Domingues 2026-05-30 15:31:41 -03:00
parent fd06a4bb07
commit 03daafeab0
4 changed files with 253 additions and 30 deletions

View file

@ -9,6 +9,7 @@ import { ptBR } from "date-fns/locale";
import { toast } from "sonner"; import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { syncAgentSkills } from "@/lib/sync-agent-skills"; import { syncAgentSkills } from "@/lib/sync-agent-skills";
import { deleteSkill } from "@/lib/delete-skill";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
@ -76,16 +77,37 @@ export function SkillCard({ skill }: { skill: Skill }) {
}; };
const handleArchive = () => { const handleArchive = () => {
updateStatus.mutate("archived", { handleArchiveMutation.mutate("archive");
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"),
});
}; };
const handleArchiveMutation = useMutation({
mutationFn: async (action: "archive" | "delete") => {
const { data, error } = await deleteSkill(skill.id, action);
if (error) throw new Error(error.message);
if (!data?.success) throw new Error("Erro ao atualizar skill.");
return data;
},
onSuccess: (data, action) => {
if (data.runtime_sync_warning) {
toast.warning(
action === "delete"
? "Skill arquivada; exclusão final do runtime pendente."
: "Skill arquivada no painel; sync com o runtime pendente.",
);
} else {
toast.success(action === "delete" ? "Skill deletada" : "Skill arquivada");
}
if (action === "archive") {
setConfirmArchive(false);
} else {
setConfirmDelete(false);
}
qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] });
},
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao atualizar"),
});
const handleRestore = () => { const handleRestore = () => {
updateStatus.mutate("draft", { updateStatus.mutate("draft", {
onSuccess: async () => { onSuccess: async () => {
@ -104,15 +126,20 @@ export function SkillCard({ skill }: { skill: Skill }) {
const handleDelete = useMutation({ const handleDelete = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { error } = await supabase.from("skills").delete().eq("id", skill.id); const { data, error } = await deleteSkill(skill.id, "delete");
if (error) throw error; if (error) throw new Error(error.message);
if (!data?.success) throw new Error("Erro ao deletar skill.");
return data;
}, },
onSuccess: async () => { onSuccess: (data) => {
toast.success("Skill deletada"); if (data.runtime_sync_warning) {
toast.warning("Skill arquivada; exclusão final do runtime pendente.");
} else {
toast.success("Skill deletada");
}
qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] }); qc.invalidateQueries({ queryKey: ["user-limits"] });
setConfirmDelete(false); setConfirmDelete(false);
await syncRuntimeAfterMutation("Skill deletada");
}, },
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"), onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro ao deletar"),
}); });

22
src/lib/delete-skill.ts Normal file
View file

@ -0,0 +1,22 @@
"use client";
import { invokeFunction } from "@/lib/invoke-function";
export interface DeleteSkillResponse {
success: boolean;
skill_id: string;
agent_instance_id: string;
archived: boolean;
deleted: boolean;
runtime_sync_warning: string | null;
}
export async function deleteSkill(
skillId: string,
action: "archive" | "delete" = "archive",
) {
return await invokeFunction<DeleteSkillResponse>("delete-skill", {
skill_id: skillId,
action,
});
}

View file

@ -12,7 +12,7 @@ import { toast } from "sonner";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useSkill } from "@/hooks/use-skills"; import { useSkill } from "@/hooks/use-skills";
import { syncAgentSkills } from "@/lib/sync-agent-skills"; import { deleteSkill } from "@/lib/delete-skill";
import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge"; import { SkillStatusBadge } from "@/components/mika/skills/SkillStatusBadge";
import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel"; import { SkillTestPanel } from "@/components/mika/skills/SkillTestPanel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -171,25 +171,19 @@ function SkillDetailPage() {
// Archive // Archive
const archiveSkill = useMutation({ const archiveSkill = useMutation({
mutationFn: async () => { mutationFn: async () => {
const { error } = await supabase const { data, error } = await deleteSkill(id, "archive");
.from("skills") if (error) throw new Error(error.message);
.update({ status: "archived", updated_at: new Date().toISOString() }) if (!data?.success) throw new Error("Erro ao arquivar skill.");
.eq("id", id); return data;
if (error) throw error;
}, },
onSuccess: () => { onSuccess: (data) => {
toast.success("Skill arquivada"); if (data.runtime_sync_warning) {
toast.warning("Skill arquivada no painel; sync com o runtime pendente.");
} else {
toast.success("Skill arquivada");
}
qc.invalidateQueries({ queryKey: ["skills"] }); qc.invalidateQueries({ queryKey: ["skills"] });
qc.invalidateQueries({ queryKey: ["user-limits"] }); qc.invalidateQueries({ queryKey: ["user-limits"] });
if (skill.data?.agent_instance_id) {
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" }); navigate({ to: "/painel/skills" });
}, },
onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"), onError: (e: unknown) => toast.error(e instanceof Error ? e.message : "Erro"),

View file

@ -0,0 +1,180 @@
// delete-skill (authenticated)
//
// Archives or deletes a Mika-managed skill through the server so the runtime is
// synchronized before the platform removes the source row.
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") ?? "";
type DeleteSkillAction = "archive" | "delete";
interface DeleteSkillBody {
skill_id?: string;
action?: DeleteSkillAction;
}
interface SkillForDelete {
id: string;
user_id: string;
agent_instance_id: string;
name: string;
status: string;
is_default: boolean | null;
}
function jsonResponse(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function isValidAction(action: unknown): action is DeleteSkillAction {
return action === "archive" || action === "delete";
}
async function syncSkills(
// deno-lint-ignore no-explicit-any
admin: any,
agentInstanceId: string,
) {
return await syncAgentSkillsSnapshot({
supabase: admin,
agentInstanceId,
railwayToken: RAILWAY_API_TOKEN,
apiKey: HERMES_API_SERVER_KEY,
});
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "POST") {
return jsonResponse(405, { error: "method not allowed" });
}
const authHeader = req.headers.get("Authorization") ?? "";
const jwt = authHeader.replace(/^Bearer\s+/i, "");
if (!jwt) {
return jsonResponse(401, { error: "missing authorization" });
}
let body: DeleteSkillBody;
try {
body = await req.json() as DeleteSkillBody;
} catch {
return jsonResponse(400, { error: "invalid json body" });
}
if (!body.skill_id) {
return jsonResponse(400, { error: "skill_id required" });
}
const action = body.action ?? "archive";
if (!isValidAction(action)) {
return jsonResponse(400, { error: "invalid action" });
}
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 admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
auth: { persistSession: false, autoRefreshToken: false },
});
const { data: skillData, error: skillErr } = await admin
.from("skills")
.select("id, user_id, agent_instance_id, name, status, is_default")
.eq("id", body.skill_id)
.maybeSingle();
if (skillErr) {
return jsonResponse(500, { error: "failed to load skill", detail: skillErr.message });
}
if (!skillData) {
return jsonResponse(404, { error: "skill not found" });
}
const skill = skillData as SkillForDelete;
const { data: isAdmin, error: roleErr } = await admin.rpc("has_role", {
_user_id: userData.user.id,
_role: "admin",
});
if (roleErr) {
return jsonResponse(500, { error: "failed to resolve role" });
}
if (skill.user_id !== userData.user.id && !isAdmin) {
return jsonResponse(403, { error: "forbidden" });
}
if (skill.is_default) {
return jsonResponse(403, { error: "default skills cannot be archived or deleted" });
}
if (skill.status !== "archived") {
const { error: archiveErr } = await admin
.from("skills")
.update({
status: "archived",
updated_at: new Date().toISOString(),
})
.eq("id", skill.id);
if (archiveErr) {
return jsonResponse(500, { error: "failed to archive skill", detail: archiveErr.message });
}
}
let runtimeSyncWarning: string | null = null;
try {
await syncSkills(admin, skill.agent_instance_id);
} catch (err) {
runtimeSyncWarning = errorMessage(err);
console.error("delete-skill runtime sync warning:", runtimeSyncWarning);
}
let deleted = false;
if (action === "delete" && !runtimeSyncWarning) {
const { error: deleteErr } = await admin
.from("skills")
.delete()
.eq("id", skill.id);
if (deleteErr) {
runtimeSyncWarning = `DB delete failed after archive sync: ${deleteErr.message}`;
console.error("delete-skill hard delete warning:", runtimeSyncWarning);
} else {
deleted = true;
}
}
return jsonResponse(200, {
success: true,
skill_id: skill.id,
agent_instance_id: skill.agent_instance_id,
archived: true,
deleted,
runtime_sync_warning: runtimeSyncWarning,
});
});