mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 09:16:46 +00:00
Migrou banco e deployou 3 funcs
X-Lovable-Edit-ID: edt-c097b408-7a76-4a15-8fe9-3bdd8165ca69 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
9aeab9c17f
8 changed files with 918 additions and 1 deletions
|
|
@ -59,6 +59,13 @@ export type Database = {
|
|||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "agent_instances_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: true
|
||||
referencedRelation: "user_skill_limits"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
enterprise_leads: {
|
||||
|
|
@ -214,6 +221,183 @@ export type Database = {
|
|||
}
|
||||
Relationships: []
|
||||
}
|
||||
skill_test_runs: {
|
||||
Row: {
|
||||
created_at: string
|
||||
duration_ms: number | null
|
||||
error_message: string | null
|
||||
id: string
|
||||
skill_version_id: string
|
||||
status: string
|
||||
test_input: string
|
||||
test_output: string | null
|
||||
test_type: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
duration_ms?: number | null
|
||||
error_message?: string | null
|
||||
id?: string
|
||||
skill_version_id: string
|
||||
status: string
|
||||
test_input: string
|
||||
test_output?: string | null
|
||||
test_type?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
duration_ms?: number | null
|
||||
error_message?: string | null
|
||||
id?: string
|
||||
skill_version_id?: string
|
||||
status?: string
|
||||
test_input?: string
|
||||
test_output?: string | null
|
||||
test_type?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "skill_test_runs_skill_version_id_fkey"
|
||||
columns: ["skill_version_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "skill_versions"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skill_test_runs_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skill_test_runs_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_skill_limits"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
skill_versions: {
|
||||
Row: {
|
||||
created_at: string
|
||||
created_by: string
|
||||
form_inputs: Json
|
||||
id: string
|
||||
is_live: boolean
|
||||
markdown_content: string
|
||||
skill_id: string
|
||||
version_number: number
|
||||
}
|
||||
Insert: {
|
||||
created_at?: string
|
||||
created_by: string
|
||||
form_inputs: Json
|
||||
id?: string
|
||||
is_live?: boolean
|
||||
markdown_content: string
|
||||
skill_id: string
|
||||
version_number: number
|
||||
}
|
||||
Update: {
|
||||
created_at?: string
|
||||
created_by?: string
|
||||
form_inputs?: Json
|
||||
id?: string
|
||||
is_live?: boolean
|
||||
markdown_content?: string
|
||||
skill_id?: string
|
||||
version_number?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "skill_versions_created_by_fkey"
|
||||
columns: ["created_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skill_versions_created_by_fkey"
|
||||
columns: ["created_by"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_skill_limits"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skill_versions_skill_id_fkey"
|
||||
columns: ["skill_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "skills"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
skills: {
|
||||
Row: {
|
||||
agent_instance_id: string
|
||||
created_at: string
|
||||
current_version_id: string | null
|
||||
description: string
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
trigger_keywords: string
|
||||
updated_at: string
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
agent_instance_id: string
|
||||
created_at?: string
|
||||
current_version_id?: string | null
|
||||
description: string
|
||||
id?: string
|
||||
name: string
|
||||
status?: string
|
||||
trigger_keywords: string
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
agent_instance_id?: string
|
||||
created_at?: string
|
||||
current_version_id?: string | null
|
||||
description?: string
|
||||
id?: string
|
||||
name?: string
|
||||
status?: string
|
||||
trigger_keywords?: string
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "skills_agent_instance_id_fkey"
|
||||
columns: ["agent_instance_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "agent_instances"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skills_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "skills_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_skill_limits"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
stripe_webhook_events: {
|
||||
Row: {
|
||||
event_type: string
|
||||
|
|
@ -308,11 +492,26 @@ export type Database = {
|
|||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "subscriptions_user_id_fkey"
|
||||
columns: ["user_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "user_skill_limits"
|
||||
referencedColumns: ["user_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
user_skill_limits: {
|
||||
Row: {
|
||||
current_skills_count: number | null
|
||||
max_skills: number | null
|
||||
plan_slug: string | null
|
||||
user_id: string | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
has_active_subscription: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
supabase/functions/_shared/cors.ts
Normal file
6
supabase/functions/_shared/cors.ts
Normal 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",
|
||||
};
|
||||
114
supabase/functions/generate-skill-markdown/index.ts
Normal file
114
supabase/functions/generate-skill-markdown/index.ts
Normal 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" } },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
148
supabase/functions/publish-skill-version/index.ts
Normal file
148
supabase/functions/publish-skill-version/index.ts
Normal 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" } },
|
||||
);
|
||||
});
|
||||
177
supabase/functions/test-skill-dry-run/index.ts
Normal file
177
supabase/functions/test-skill-dry-run/index.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
-- ============================================================
|
||||
-- FASE 2: Skill Studio — Schema completo
|
||||
-- ============================================================
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 1) agent_instances: garantir unicidade por user_id e popular retroativamente
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
-- Garante que cada usuário tem no máximo 1 agente (necessário para ON CONFLICT no webhook)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS agent_instances_user_id_key
|
||||
ON public.agent_instances(user_id);
|
||||
|
||||
-- Migração retroativa: cria agent_instance 'active' para quem já tem subscription ativa/trial
|
||||
INSERT INTO public.agent_instances (user_id, status, created_at, updated_at)
|
||||
SELECT
|
||||
s.user_id,
|
||||
'active' AS status,
|
||||
now(),
|
||||
now()
|
||||
FROM public.subscriptions s
|
||||
WHERE s.status IN ('active', 'trialing')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.agent_instances ai WHERE ai.user_id = s.user_id
|
||||
);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 2) Tabela: skills
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE public.skills (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
agent_instance_id uuid NOT NULL REFERENCES public.agent_instances(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL,
|
||||
trigger_keywords text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'testing', 'active', 'disabled', 'archived')),
|
||||
current_version_id uuid, -- SEM FK por design (ver comment)
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.skills.current_version_id IS
|
||||
'Aponta para skill_versions(id). Sem FK constraint para evitar dependência circular com skill_versions (que referencia skills.id). A integridade é garantida pela aplicação e pela edge function publish-skill-version.';
|
||||
|
||||
CREATE INDEX idx_skills_user_id ON public.skills(user_id);
|
||||
CREATE INDEX idx_skills_agent_instance_id ON public.skills(agent_instance_id);
|
||||
CREATE INDEX idx_skills_status ON public.skills(status);
|
||||
|
||||
-- Invariante: nome único por usuário entre não-arquivadas
|
||||
CREATE UNIQUE INDEX skills_unique_name_per_user
|
||||
ON public.skills(user_id, name)
|
||||
WHERE status != 'archived';
|
||||
|
||||
ALTER TABLE public.skills ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Usuários veem suas próprias skills"
|
||||
ON public.skills FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Usuários criam suas próprias skills"
|
||||
ON public.skills FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Usuários atualizam suas próprias skills"
|
||||
ON public.skills FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Usuários deletam suas próprias skills"
|
||||
ON public.skills FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Trigger updated_at
|
||||
CREATE TRIGGER trg_skills_updated_at
|
||||
BEFORE UPDATE ON public.skills
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 3) Tabela: skill_versions
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE public.skill_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
skill_id uuid NOT NULL REFERENCES public.skills(id) ON DELETE CASCADE,
|
||||
version_number int NOT NULL,
|
||||
markdown_content text NOT NULL CHECK (length(markdown_content) <= 50000),
|
||||
form_inputs jsonb NOT NULL,
|
||||
is_live boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid NOT NULL REFERENCES public.profiles(id),
|
||||
UNIQUE (skill_id, version_number)
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.skill_versions.form_inputs IS
|
||||
'Snapshot dos campos do formulário usados para gerar esta versão. Schema esperado: { name: string, description: string, trigger_keywords: string, expected_inputs: string|null, steps: string, required_tools: string[], success_criteria: string, example_use_case: string|null }';
|
||||
|
||||
CREATE INDEX idx_skill_versions_skill_id ON public.skill_versions(skill_id);
|
||||
CREATE INDEX idx_skill_versions_is_live ON public.skill_versions(skill_id) WHERE is_live = true;
|
||||
|
||||
-- INVARIANTE CRÍTICA: apenas 1 versão live por skill (garantido no banco)
|
||||
CREATE UNIQUE INDEX skill_versions_one_live_per_skill
|
||||
ON public.skill_versions(skill_id)
|
||||
WHERE is_live = true;
|
||||
|
||||
ALTER TABLE public.skill_versions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Usuários veem versões das próprias skills"
|
||||
ON public.skill_versions FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Usuários criam versões das próprias skills"
|
||||
ON public.skill_versions FOR INSERT
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Usuários atualizam versões das próprias skills"
|
||||
ON public.skill_versions FOR UPDATE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Usuários deletam versões das próprias skills"
|
||||
ON public.skill_versions FOR DELETE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM public.skills s WHERE s.id = skill_versions.skill_id AND s.user_id = auth.uid()
|
||||
));
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 4) Tabela: skill_test_runs
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE public.skill_test_runs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
skill_version_id uuid NOT NULL REFERENCES public.skill_versions(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
|
||||
test_input text NOT NULL,
|
||||
test_output text,
|
||||
status text NOT NULL CHECK (status IN ('running', 'success', 'error')),
|
||||
error_message text,
|
||||
duration_ms int,
|
||||
test_type text NOT NULL DEFAULT 'dry_run' CHECK (test_type IN ('dry_run', 'real')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_skill_test_runs_skill_version_id ON public.skill_test_runs(skill_version_id);
|
||||
CREATE INDEX idx_skill_test_runs_user_id ON public.skill_test_runs(user_id);
|
||||
|
||||
ALTER TABLE public.skill_test_runs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Usuários veem seus próprios test runs"
|
||||
ON public.skill_test_runs FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Usuários criam seus próprios test runs"
|
||||
ON public.skill_test_runs FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 5) View: user_skill_limits
|
||||
-- ------------------------------------------------------------
|
||||
CREATE VIEW public.user_skill_limits
|
||||
WITH (security_invoker = true)
|
||||
AS
|
||||
SELECT
|
||||
p.id AS user_id,
|
||||
pl.slug AS plan_slug,
|
||||
CASE
|
||||
WHEN pl.slug IS NULL THEN NULL
|
||||
WHEN pl.slug = 'basic' THEN 5
|
||||
WHEN pl.slug = 'starter' THEN 15
|
||||
WHEN pl.slug = 'professional' THEN 50
|
||||
WHEN pl.slug = 'enterprise' THEN 999999
|
||||
ELSE NULL
|
||||
END AS max_skills,
|
||||
(
|
||||
SELECT count(*)::int
|
||||
FROM public.skills sk
|
||||
WHERE sk.user_id = p.id AND sk.status != 'archived'
|
||||
) AS current_skills_count
|
||||
FROM public.profiles p
|
||||
LEFT JOIN public.subscriptions s
|
||||
ON s.user_id = p.id
|
||||
AND s.status IN ('active', 'trialing')
|
||||
LEFT JOIN public.plans pl
|
||||
ON pl.id = s.plan_id;
|
||||
|
||||
GRANT SELECT ON public.user_skill_limits TO authenticated;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 6) Função + trigger: enforce_skill_limit (BEFORE INSERT)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.enforce_skill_limit()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_max int;
|
||||
v_count int;
|
||||
BEGIN
|
||||
SELECT max_skills, current_skills_count
|
||||
INTO v_max, v_count
|
||||
FROM public.user_skill_limits
|
||||
WHERE user_id = NEW.user_id;
|
||||
|
||||
IF v_max IS NULL THEN
|
||||
RAISE EXCEPTION 'Sem assinatura ativa para criar skills'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF v_count >= v_max THEN
|
||||
RAISE EXCEPTION 'Limite de skills atingido para o plano'
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_enforce_skill_limit
|
||||
BEFORE INSERT ON public.skills
|
||||
FOR EACH ROW EXECUTE FUNCTION public.enforce_skill_limit();
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 7) Função + trigger: cleanup_old_skill_versions (AFTER INSERT)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.cleanup_old_skill_versions()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
DELETE FROM public.skill_versions
|
||||
WHERE skill_id = NEW.skill_id
|
||||
AND is_live = false
|
||||
AND id NOT IN (
|
||||
SELECT id FROM public.skill_versions
|
||||
WHERE skill_id = NEW.skill_id
|
||||
AND is_live = false
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 12
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trigger_cleanup_skill_versions
|
||||
AFTER INSERT ON public.skill_versions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.cleanup_old_skill_versions();
|
||||
Loading…
Add table
Add a link
Reference in a new issue