Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot] 2026-04-17 18:48:11 +00:00
parent 2c5ca61b19
commit 71d0c59d69
6 changed files with 466 additions and 0 deletions

View file

@ -5,3 +5,12 @@ verify_jwt = false
[functions.get-paddle-price]
verify_jwt = false
[functions.generate-skill-markdown]
verify_jwt = true
[functions.test-skill-dry-run]
verify_jwt = true
[functions.publish-skill-version]
verify_jwt = true

View file

@ -0,0 +1,6 @@
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
};

View file

@ -0,0 +1,114 @@
// Gera o markdown de uma skill no padrão agentskills.io via Lovable AI Gateway.
import { corsHeaders } from "../_shared/cors.ts";
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!;
const MODEL = "google/gemini-2.5-flash";
const MAX_LEN = 50000;
const SYSTEM_PROMPT = `Você é um especialista em criar skills para o Hermes Agent no padrão agentskills.io. Gere um arquivo markdown completo e bem estruturado para a skill descrita pelo usuário. O arquivo deve seguir esta estrutura exata: cabeçalho YAML com name, description, trigger_keywords; seção ## Quando usar; seção ## Inputs esperados (se houver); seção ## Passo a passo numerada; seção ## Ferramentas necessárias; seção ## Critério de sucesso; seção opcional ## Exemplo. Use linguagem clara, imperativa e em português. Não adicione comentários fora do markdown. Retorne APENAS o conteúdo do arquivo .md, sem code fences.`;
interface FormInputs {
name: string;
description: string;
trigger_keywords: string;
expected_inputs?: string | null;
steps: string;
required_tools: string[];
success_criteria: string;
example_use_case?: string | null;
}
function formatUserMessage(f: FormInputs): string {
return [
`Nome: ${f.name}`,
`Descrição: ${f.description}`,
`Palavras-chave de gatilho: ${f.trigger_keywords}`,
f.expected_inputs ? `Inputs esperados: ${f.expected_inputs}` : null,
`Passo a passo:\n${f.steps}`,
`Ferramentas necessárias: ${f.required_tools.join(", ")}`,
`Critério de sucesso: ${f.success_criteria}`,
f.example_use_case ? `Exemplo de uso: ${f.example_use_case}` : null,
]
.filter(Boolean)
.join("\n\n");
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const body = await req.json();
const form_inputs = body?.form_inputs as FormInputs | undefined;
if (!form_inputs?.name || !form_inputs?.description || !form_inputs?.steps) {
return new Response(
JSON.stringify({ error: "form_inputs incompleto" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: formatUserMessage(form_inputs) },
],
}),
});
if (aiRes.status === 429) {
return new Response(
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (aiRes.status === 402) {
return new Response(
JSON.stringify({ error: "Crédito de IA esgotado. Entre em contato com o suporte." }),
{ status: 402, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (!aiRes.ok) {
const txt = await aiRes.text();
console.error("AI gateway error:", aiRes.status, txt);
return new Response(
JSON.stringify({ error: "Falha ao gerar skill. Tente novamente." }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const data = await aiRes.json();
let markdown_content: string = data?.choices?.[0]?.message?.content ?? "";
markdown_content = markdown_content.trim();
if (!markdown_content) {
return new Response(
JSON.stringify({ error: "A IA retornou conteúdo vazio. Tente novamente." }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (markdown_content.length > MAX_LEN) {
markdown_content = markdown_content.slice(0, MAX_LEN);
}
return new Response(
JSON.stringify({ markdown_content }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e) {
console.error("generate-skill-markdown error:", e);
return new Response(
JSON.stringify({ error: "Erro inesperado ao gerar skill." }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});

View file

@ -123,6 +123,18 @@ async function upsertSubscription(data: any, env: PaddleEnv) {
// Atualiza paddle_customer_id no profile
await supabase.from('profiles').update({ paddle_customer_id: customerId }).eq('id', userId);
// Provisiona agent_instance se ainda não existir (idempotente via unique index em user_id).
// TODO Fase 5: dispatch provisioning job (criar container Docker na VPS via Coolify API)
if (status === 'active' || status === 'trialing') {
const { error: agentErr } = await supabase
.from('agent_instances')
.insert({ user_id: userId, status: 'provisioning' });
// 23505 = já existe (esperado para usuários retornando), ignora
if (agentErr && agentErr.code !== '23505') {
console.error('Failed to provision agent_instance:', agentErr);
}
}
}
async function markCanceled(data: any, env: PaddleEnv) {

View file

@ -0,0 +1,148 @@
// 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";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_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)")
.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;
// 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" },
});
}
return new Response(
JSON.stringify({ success: true, version_number: versionRow.version_number }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
});

View file

@ -0,0 +1,177 @@
// Executa um dry-run de uma skill: simula a execução via LLM, sem acionar ferramentas reais.
import { createClient } from "npm:@supabase/supabase-js@2";
import { corsHeaders } from "../_shared/cors.ts";
const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY")!;
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const MODEL = "google/gemini-2.5-flash";
const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de teste (dry-run). Você NÃO deve executar ferramentas reais — apenas simular. Receba a definição da skill e o input do usuário. Descreva passo a passo o que você faria, qual ferramenta acionaria em cada momento, e qual seria o output final. Seja claro e didático. Se a skill estiver mal definida ou ambígua para o input dado, explique o problema.`;
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Auth: extrai user do JWT
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; test_input?: 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, test_input } = body;
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
return new Response(
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
// Verifica ownership: skill_version -> skill -> user_id
const { data: versionRow, error: vErr } = await admin
.from("skill_versions")
.select("id, markdown_content, skills!inner(user_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 supabase nested type
if (versionRow.skills.user_id !== userId) {
return new Response(JSON.stringify({ error: "Acesso negado" }), {
status: 403,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Cria registro running
const { data: runRow, error: runErr } = await admin
.from("skill_test_runs")
.insert({
skill_version_id,
user_id: userId,
test_input,
status: "running",
test_type: "dry_run",
})
.select("id")
.single();
if (runErr || !runRow) {
console.error("Failed to create test run:", runErr);
return new Response(JSON.stringify({ error: "Falha ao registrar teste" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const runId = runRow.id;
const startedAt = Date.now();
try {
const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${LOVABLE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{
role: "user",
content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`,
},
],
}),
});
if (aiRes.status === 429) {
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
status: "error",
error_message: "Muitas requisições. Aguarde 1 minuto.",
duration_ms: duration,
})
.eq("id", runId);
return new Response(
JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
{ status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (!aiRes.ok) {
const txt = await aiRes.text();
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({
status: "error",
error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
duration_ms: duration,
})
.eq("id", runId);
return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const data = await aiRes.json();
const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
const duration = Date.now() - startedAt;
await admin
.from("skill_test_runs")
.update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId);
return new Response(
JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e) {
const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido";
await admin
.from("skill_test_runs")
.update({ status: "error", error_message: msg, duration_ms: duration })
.eq("id", runId);
return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});