fix: harden skill defaults and dry-run preview

This commit is contained in:
Felipe Domingues 2026-05-28 18:21:24 -03:00
parent 354ca092c9
commit fdef327c5e
11 changed files with 495 additions and 254 deletions

View file

@ -220,11 +220,16 @@ export async function ensureDefaultSkillsForAgent(
for (const template of DEFAULT_HERMES_SKILLS) {
const { data: existing, error: existingErr } = await supabase
.from("skills")
.select("id")
.select("id, current_version_id, is_default")
.eq("user_id", agent.user_id)
.eq("name", template.name)
.neq("status", "archived")
.maybeSingle();
const existingSkill = existing as {
id: string;
current_version_id: string | null;
is_default?: boolean | null;
} | null;
if (existingErr) {
result.errors.push(
@ -233,11 +238,20 @@ export async function ensureDefaultSkillsForAgent(
continue;
}
if (existing) {
if (existingSkill?.current_version_id) {
result.skipped_count += 1;
continue;
}
if (existingSkill) {
if (existingSkill.is_default) {
await supabase.from("skills").delete().eq("id", existingSkill.id);
} else {
result.skipped_count += 1;
continue;
}
}
const { data: skillData, error: skillErr } = await supabase
.from("skills")
.insert({
@ -246,6 +260,7 @@ export async function ensureDefaultSkillsForAgent(
name: template.name,
description: template.description,
trigger_keywords: template.trigger_keywords,
is_default: true,
status: "draft",
})
.select("id")
@ -274,6 +289,7 @@ export async function ensureDefaultSkillsForAgent(
const version = versionData as { id: string } | null;
if (versionErr || !version) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
);
@ -290,6 +306,7 @@ export async function ensureDefaultSkillsForAgent(
.eq("id", skill.id);
if (updateErr) {
await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
continue;
}

View file

@ -11,6 +11,57 @@ const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de te
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
class DryRunError extends Error {
status: number;
clientMessage: string;
constructor(status: number, clientMessage: string, logMessage = clientMessage) {
super(logMessage);
this.status = status;
this.clientMessage = clientMessage;
}
}
async function generateDryRunOutput(markdownContent: string, testInput: string): Promise<string> {
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${markdownContent}\n\`\`\`\n\nInput do usuário: ${testInput}`,
},
],
}),
});
if (aiRes.status === 429) {
throw new DryRunError(
429,
"Muitas requisições. Aguarde 1 minuto e tente novamente.",
"Muitas requisições. Aguarde 1 minuto.",
);
}
if (!aiRes.ok) {
const txt = await aiRes.text();
throw new DryRunError(
500,
"Falha ao executar teste.",
`AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
);
}
const data = await aiRes.json();
return (data?.choices?.[0]?.message?.content ?? "").trim();
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
@ -34,7 +85,7 @@ Deno.serve(async (req) => {
}
const userId = userData.user.id;
let body: { skill_version_id?: string; test_input?: string };
let body: { skill_version_id?: string; markdown_content?: string; test_input?: string };
try {
body = await req.json();
} catch {
@ -44,19 +95,57 @@ Deno.serve(async (req) => {
});
}
const { skill_version_id, test_input } = body;
if (!skill_version_id || !test_input || test_input.trim().length === 0) {
const skillVersionId =
typeof body.skill_version_id === "string" ? body.skill_version_id.trim() : "";
const markdownContent =
typeof body.markdown_content === "string" ? body.markdown_content.trim() : "";
const testInput = typeof body.test_input === "string" ? body.test_input.trim() : "";
if (testInput.length === 0) {
return new Response(JSON.stringify({ error: "test_input é obrigatório" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (!skillVersionId && !markdownContent) {
return new Response(
JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
if (markdownContent.length > 50000) {
return new Response(JSON.stringify({ error: "markdown_content excede 50000 caracteres" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
if (markdownContent) {
const startedAt = Date.now();
try {
const test_output = await generateDryRunOutput(markdownContent, testInput);
return new Response(
JSON.stringify({ test_output, duration_ms: Date.now() - startedAt, status: "success" }),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (e) {
const msg = e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.";
const status = e instanceof DryRunError ? e.status : 500;
console.error("Stateless skill dry-run failed:", e instanceof Error ? e.message : String(e));
return new Response(JSON.stringify({ error: msg }), {
status,
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)
.eq("id", skillVersionId)
.maybeSingle();
if (vErr || !versionRow) {
@ -77,9 +166,9 @@ Deno.serve(async (req) => {
const { data: runRow, error: runErr } = await admin
.from("skill_test_runs")
.insert({
skill_version_id,
skill_version_id: skillVersionId,
user_id: userId,
test_input,
test_input: testInput,
status: "running",
test_type: "dry_run",
})
@ -98,59 +187,7 @@ Deno.serve(async (req) => {
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 test_output = await generateDryRunOutput(versionRow.markdown_content, testInput);
const duration = Date.now() - startedAt;
await admin
@ -158,10 +195,10 @@ Deno.serve(async (req) => {
.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" } },
);
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";
@ -169,9 +206,14 @@ Deno.serve(async (req) => {
.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" },
});
return new Response(
JSON.stringify({
error: e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.",
}),
{
status: e instanceof DryRunError ? e.status : 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View file

@ -0,0 +1,126 @@
-- Mark platform-seeded Hermes skills separately from user-created skills.
-- Default skills stay active and synced to the runtime, but do not consume the
-- customer's custom skill quota.
ALTER TABLE public.skills
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
COMMENT ON COLUMN public.skills.is_default IS
'True for Mika/Hermes skills seeded by the platform. These do not count against the custom skill quota.';
UPDATE public.skills
SET is_default = true
WHERE is_default = false
AND (
(
name = 'Resumo diario'
AND description = 'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.'
)
OR (
name = 'Planejamento semanal'
AND description = 'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.'
)
OR (
name = 'Preparar reuniao'
AND description = 'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
)
);
CREATE OR REPLACE 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'
AND COALESCE(sk.is_default, false) = false
) 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;
CREATE OR REPLACE FUNCTION public.protect_skill_default_flag()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
BEGIN
IF v_request_role = 'service_role' THEN
RETURN NEW;
END IF;
IF TG_OP = 'UPDATE' THEN
NEW.is_default := OLD.is_default;
ELSE
NEW.is_default := false;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trigger_protect_skill_default_flag ON public.skills;
CREATE TRIGGER trigger_protect_skill_default_flag
BEFORE INSERT OR UPDATE ON public.skills
FOR EACH ROW EXECUTE FUNCTION public.protect_skill_default_flag();
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;
v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
BEGIN
IF COALESCE(NEW.is_default, false) THEN
IF v_request_role = 'service_role' THEN
RETURN NEW;
END IF;
NEW.is_default := false;
END IF;
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;
$$;
REVOKE EXECUTE ON FUNCTION public.protect_skill_default_flag() FROM anon, authenticated, public;
REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public;