// 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. 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); Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } // Auth const authHeader = req.headers.get("Authorization"); if (!authHeader) { return new Response(JSON.stringify({ error: "Não autenticado" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const token = authHeader.replace("Bearer ", ""); const { data: userData, error: userErr } = await admin.auth.getUser(token); if (userErr || !userData?.user) { return new Response(JSON.stringify({ error: "Não autenticado" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const userId = userData.user.id; let body: { skill_version_id?: string }; try { body = await req.json(); } catch { return new Response(JSON.stringify({ error: "JSON inválido" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { skill_version_id } = body; if (!skill_version_id) { return new Response(JSON.stringify({ error: "skill_version_id é obrigatório" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // 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, agent_instance_id)") .eq("id", skill_version_id) .maybeSingle(); if (vErr || !versionRow) { return new Response(JSON.stringify({ error: "Versão não encontrada" }), { status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // @ts-expect-error nested const skillUserId: string = versionRow.skills.user_id; if (skillUserId !== userId) { return new Response(JSON.stringify({ error: "Acesso negado" }), { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Idempotência: já é live, no-op if (versionRow.is_live === true) { return new Response( JSON.stringify({ success: true, no_op: true, version_number: versionRow.version_number }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } 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. // O unique index parcial skill_versions_one_live_per_skill protege contra race. // Se duas execuções rodarem em paralelo, uma delas falhará no passo 2 com 23505. // Passo 1: desmarcar todas as outras versões como live const { error: clearErr } = await admin .from("skill_versions") .update({ is_live: false }) .eq("skill_id", skillId) .eq("is_live", true); if (clearErr) { console.error("Clear live error:", clearErr); return new Response(JSON.stringify({ error: "Falha ao publicar (clear)." }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Passo 2: marcar a versão alvo como live const { error: setErr } = await admin .from("skill_versions") .update({ is_live: true }) .eq("id", skill_version_id); if (setErr) { // Race condition na invariante de banco if (setErr.code === "23505") { return new Response( JSON.stringify({ error: "Conflito de concorrência. Tente novamente." }), { status: 409, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); } console.error("Set live error:", setErr); return new Response(JSON.stringify({ error: "Falha ao publicar (set)." }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } // Passo 3: atualizar skills.current_version_id e status const { error: updSkillErr } = await admin .from("skills") .update({ current_version_id: skill_version_id, status: "active", updated_at: new Date().toISOString(), }) .eq("id", skillId); if (updSkillErr) { console.error("Update skill error:", updSkillErr); return new Response(JSON.stringify({ error: "Falha ao publicar (skill)." }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } 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, ...syncResult, }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }, ); });