mika-agent-assist/supabase/functions/publish-skill-version/index.ts
Felipe Domingues d87ea2657c
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
2026-04-30 20:41:06 -03:00

179 lines
6.1 KiB
TypeScript

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