From c6b67e98b896994d3e6d9a451a01ac4d251059ab Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 12:46:33 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- .../mika/cronjobs/CronjobWizard.tsx | 63 ++-- src/hooks/use-cronjobs.ts | 21 +- src/routes/painel.integracoes.$slug.tsx | 43 +-- supabase/config.toml | 2 + supabase/functions/_shared/default-skills.ts | 301 ++++++++++++++++ .../create-cronjob-from-agent/index.ts | 112 +++++- .../create-skill-from-agent/index.ts | 334 ++++++++++++++++++ supabase/functions/provision-agent/index.ts | 189 +++++++--- supabase/functions/railway-webhook/index.ts | 109 ++++-- 9 files changed, 1033 insertions(+), 141 deletions(-) create mode 100644 supabase/functions/_shared/default-skills.ts create mode 100644 supabase/functions/create-skill-from-agent/index.ts diff --git a/src/components/mika/cronjobs/CronjobWizard.tsx b/src/components/mika/cronjobs/CronjobWizard.tsx index 5b8aff8..0f661e1 100644 --- a/src/components/mika/cronjobs/CronjobWizard.tsx +++ b/src/components/mika/cronjobs/CronjobWizard.tsx @@ -11,7 +11,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { invokeFunction } from "@/lib/invoke-function"; import { syncAgentRuntime } from "@/lib/sync-agent-runtime"; -import { useCreateCronjob } from "@/hooks/use-cronjobs"; +import { markCronjobRuntimeSyncError, useCreateCronjob } from "@/hooks/use-cronjobs"; import { useAvailableMcps, useUserIntegrations } from "@/hooks/use-integrations"; import { useAgentInstance } from "@/hooks/use-agent-instance"; import { useProfile } from "@/hooks/use-profile"; @@ -54,9 +54,12 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { const { data: integrations = [] } = useUserIntegrations(); const createMut = useCreateCronjob(); - const tz = profile && "timezone" in profile && typeof (profile as { timezone?: string }).timezone === "string" - ? (profile as { timezone: string }).timezone - : "America/Sao_Paulo"; + const tz = + profile && + "timezone" in profile && + typeof (profile as { timezone?: string }).timezone === "string" + ? (profile as { timezone: string }).timezone + : "America/Sao_Paulo"; const mcpsBySlug = useMemo(() => { const m = new Map(); @@ -83,10 +86,10 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { return; } setParsing(true); - const { data, error } = await invokeFunction( - "parse-cronjob-natural-language", - { natural_language_input: trimmed, user_timezone: tz }, - ); + const { data, error } = await invokeFunction("parse-cronjob-natural-language", { + natural_language_input: trimmed, + user_timezone: tz, + }); setParsing(false); if (error || !data) { toast.error(error?.message ?? "Não conseguimos interpretar. Tente reescrever."); @@ -131,11 +134,14 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { timezone: tz, next_run_at: parsed?.next_run_at ?? null, }); - toast.success("Automação criada!"); - const { error: syncError } = await syncAgentRuntime(agent.id, "cronjobs"); if (syncError) { - toast.warning("Automação criada, mas o runtime do agente não sincronizou."); + await markCronjobRuntimeSyncError(job.id, syncError.message); + toast.error("Automação criada, mas não foi ativada no agente.", { + description: "Corrija o runtime e tente sincronizar novamente pelo painel.", + }); + } else { + toast.success("Automação criada e sincronizada!"); } onCreated?.(job.id); @@ -208,26 +214,29 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { parsed.confidence === "high" ? "success" : parsed.confidence === "medium" - ? "secondary" - : "destructive"; + ? "secondary" + : "destructive"; return (

Revisar interpretação

- Confiança: {parsed.confidence === "high" ? "alta" : parsed.confidence === "medium" ? "média" : "baixa"} + Confiança:{" "} + {parsed.confidence === "high" + ? "alta" + : parsed.confidence === "medium" + ? "média" + : "baixa"}
{parsed.warnings.length > 0 && ( -
+
- +
-

- Suposições da IA — confira: -

+

Suposições da IA — confira:

    {parsed.warnings.map((w, i) => (
  • {w}
  • @@ -317,7 +326,11 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { variant={connected ? "success" : "destructive"} className="gap-1" > - {connected ? : } + {connected ? ( + + ) : ( + + )} {mcp?.name ?? slug} {!connected && " (não conectado)"} @@ -331,7 +344,9 @@ export function CronjobWizard({ onCreated, onCancel }: Props) { Conecte as integrações faltantes antes de criar.

)} @@ -351,11 +366,7 @@ export function CronjobWizard({ onCreated, onCancel }: Props) {
-
-

- Você ainda não conectou {mcp.name}. -

+

Você ainda não conectou {mcp.name}.

@@ -248,9 +248,7 @@ function IntegrationDetailPage() { className="flex items-center justify-between text-sm border-b border-border last:border-0 pb-2 last:pb-0" > {j.name} - - {j.status} - + {j.status} ))} @@ -271,25 +269,20 @@ function IntegrationDetailPage() { {(status === "expired" || status === "revoked" || status === "error") && ( )} -
@@ -300,14 +293,12 @@ function IntegrationDetailPage() { setDisconnectOpen(o); if (!o) { // se desconectou, volta para a lista - queryClient - .invalidateQueries({ queryKey: ["user-integrations"] }) - .then(() => { - const stillConnected = integs.some((i) => i.id === integration.id); - if (!stillConnected) { - navigate({ to: "/painel/integracoes", search: {} }); - } - }); + queryClient.invalidateQueries({ queryKey: ["user-integrations"] }).then(() => { + const stillConnected = integs.some((i) => i.id === integration.id); + if (!stillConnected) { + navigate({ to: "/painel/integracoes", search: {} }); + } + }); } }} integrationId={integration.id} diff --git a/supabase/config.toml b/supabase/config.toml index 2983681..cc369ce 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -60,3 +60,5 @@ verify_jwt = false [functions.create-cronjob-from-agent] verify_jwt = false +[functions.create-skill-from-agent] +verify_jwt = false diff --git a/supabase/functions/_shared/default-skills.ts b/supabase/functions/_shared/default-skills.ts new file mode 100644 index 0000000..74ec5e9 --- /dev/null +++ b/supabase/functions/_shared/default-skills.ts @@ -0,0 +1,301 @@ +import type { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; + +type GenericTable = { + Row: Record; + Insert: Record; + Update: Record; + Relationships: []; +}; + +type GenericDatabase = { + public: { + Tables: Record; + Views: Record; + Functions: Record; Returns: unknown }>; + }; +}; + +type SupabaseAdminClient = ReturnType>; + +export interface DefaultSkillsEnsureResult { + agent_instance_id: string; + created_count: number; + skipped_count: number; + errors: string[]; +} + +interface DefaultSkillTemplate { + name: string; + description: string; + trigger_keywords: string; + form_inputs: Record; + markdown_content: string; +} + +const DEFAULT_HERMES_SKILLS: DefaultSkillTemplate[] = [ + { + name: "Resumo diario", + description: "Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.", + trigger_keywords: "resumo diario, resumo do dia, briefing diario, revisar dia", + form_inputs: { + name: "Resumo diario", + description: "Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.", + trigger_keywords: "resumo diario, resumo do dia, briefing diario, revisar dia", + expected_inputs: "Periodo desejado e ferramentas conectadas, quando houver.", + steps: "Consolidar agenda, tarefas e pontos pendentes. Apontar prioridades e riscos.", + required_tools: ["calendar_optional", "tasks_optional", "notes_optional"], + success_criteria: "Usuario recebe um resumo acionavel e curto.", + example_use_case: "Me manda meu resumo diario as 8h.", + }, + markdown_content: `--- +name: Resumo diario +description: Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades. +trigger_keywords: resumo diario, resumo do dia, briefing diario, revisar dia +--- + +## Quando usar + +Use quando o usuario pedir um resumo do dia, briefing, revisao diaria ou preparacao rapida para comecar/encerrar o expediente. + +## Inputs esperados + +- Periodo desejado, se o usuario informar. +- Ferramentas conectadas relevantes, como agenda, tarefas ou notas. + +## Passo a passo + +1. Identifique o periodo solicitado. Se nao houver periodo, use hoje no fuso do usuario. +2. Consulte agenda, tarefas e notas quando essas integracoes estiverem disponiveis. +3. Organize a resposta em compromissos, tarefas importantes, pendencias e sugestao de foco. +4. Se uma integracao necessaria nao estiver conectada, explique isso de forma curta e ofereca um resumo com o contexto disponivel. + +## Ferramentas necessarias + +- Calendar opcional. +- Tasks opcional. +- Notes opcional. + +## Criterio de sucesso + +O usuario recebe um resumo curto, confiavel e acionavel, sem inventar dados ausentes. + +## Exemplo + +"Me manda meu resumo diario as 8h."`, + }, + { + name: "Planejamento semanal", + description: + "Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.", + trigger_keywords: + "planejar semana, planejamento semanal, prioridades da semana, organizar semana", + form_inputs: { + name: "Planejamento semanal", + description: + "Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.", + trigger_keywords: + "planejar semana, planejamento semanal, prioridades da semana, organizar semana", + expected_inputs: "Objetivos, restricoes e contexto da semana.", + steps: "Levantar objetivos, quebrar em acoes, priorizar e sugerir agenda.", + required_tools: ["calendar_optional", "tasks_optional"], + success_criteria: "Usuario sai com prioridades e proximas acoes claras.", + example_use_case: "Me ajuda a planejar minha semana.", + }, + markdown_content: `--- +name: Planejamento semanal +description: Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes. +trigger_keywords: planejar semana, planejamento semanal, prioridades da semana, organizar semana +--- + +## Quando usar + +Use quando o usuario pedir ajuda para planejar a semana, organizar prioridades, distribuir tarefas ou revisar foco semanal. + +## Inputs esperados + +- Objetivos da semana. +- Prazos, reunioes ou restricoes conhecidas. +- Tarefas pendentes, se houver integracao disponivel. + +## Passo a passo + +1. Liste os objetivos principais mencionados pelo usuario. +2. Quebre cada objetivo em proximas acoes pequenas. +3. Sugira uma ordem de prioridade realista. +4. Se houver agenda conectada, proponha blocos de foco sem assumir disponibilidade que nao foi verificada. +5. Termine com um plano curto e facil de revisar. + +## Ferramentas necessarias + +- Calendar opcional. +- Tasks opcional. + +## Criterio de sucesso + +O usuario recebe prioridades claras, proximas acoes e uma sugestao de distribuicao da semana. + +## Exemplo + +"Me ajuda a planejar minha semana."`, + }, + { + name: "Preparar reuniao", + description: + "Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.", + trigger_keywords: "preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao", + form_inputs: { + name: "Preparar reuniao", + description: + "Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.", + trigger_keywords: "preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao", + expected_inputs: "Nome da reuniao, participantes ou tema.", + steps: "Localizar contexto, resumir objetivo, sugerir pauta e perguntas.", + required_tools: ["calendar_optional", "email_optional", "notes_optional"], + success_criteria: "Usuario chega preparado para a reuniao.", + example_use_case: "Prepara meu briefing para a reuniao com o cliente.", + }, + markdown_content: `--- +name: Preparar reuniao +description: Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis. +trigger_keywords: preparar reuniao, briefing de reuniao, pauta de reuniao, antes da reuniao +--- + +## Quando usar + +Use quando o usuario pedir preparacao para uma reuniao, briefing, pauta ou contexto antes de falar com alguem. + +## Inputs esperados + +- Nome, horario, participantes ou tema da reuniao. +- Materiais ou contexto fornecidos pelo usuario. + +## Passo a passo + +1. Identifique qual reuniao ou tema o usuario quer preparar. +2. Consulte agenda, emails ou notas quando houver integracao disponivel. +3. Resuma objetivo, contexto conhecido, riscos e decisoes pendentes. +4. Sugira uma pauta curta e perguntas uteis. +5. Se faltar contexto, diga exatamente o que falta. + +## Ferramentas necessarias + +- Calendar opcional. +- Email opcional. +- Notes opcional. + +## Criterio de sucesso + +O usuario recebe um briefing pratico para entrar na reuniao com clareza. + +## Exemplo + +"Prepara meu briefing para a reuniao com o cliente."`, + }, +]; + +export async function ensureDefaultSkillsForAgent( + supabase: SupabaseAdminClient, + agentInstanceId: string, +): Promise { + const result: DefaultSkillsEnsureResult = { + agent_instance_id: agentInstanceId, + created_count: 0, + skipped_count: 0, + errors: [], + }; + + const { data: agentData, error: agentErr } = await supabase + .from("agent_instances") + .select("id, user_id") + .eq("id", agentInstanceId) + .maybeSingle(); + const agent = agentData as { id: string; user_id: string } | null; + + if (agentErr || !agent) { + throw new Error( + `agent_instance not found for default skills: ${agentErr?.message ?? agentInstanceId}`, + ); + } + + for (const template of DEFAULT_HERMES_SKILLS) { + const { data: existing, error: existingErr } = await supabase + .from("skills") + .select("id") + .eq("user_id", agent.user_id) + .eq("name", template.name) + .neq("status", "archived") + .maybeSingle(); + + if (existingErr) { + result.errors.push( + `${template.name}: failed to check existing skill (${existingErr.message})`, + ); + continue; + } + + if (existing) { + result.skipped_count += 1; + continue; + } + + const { data: skillData, error: skillErr } = await supabase + .from("skills") + .insert({ + user_id: agent.user_id, + agent_instance_id: agent.id, + name: template.name, + description: template.description, + trigger_keywords: template.trigger_keywords, + status: "draft", + }) + .select("id") + .single(); + const skill = skillData as { id: string } | null; + + if (skillErr || !skill) { + result.errors.push( + `${template.name}: failed to create skill (${skillErr?.message ?? "unknown"})`, + ); + continue; + } + + const { data: versionData, error: versionErr } = await supabase + .from("skill_versions") + .insert({ + skill_id: skill.id, + version_number: 1, + markdown_content: template.markdown_content, + form_inputs: template.form_inputs, + is_live: true, + created_by: agent.user_id, + }) + .select("id") + .single(); + const version = versionData as { id: string } | null; + + if (versionErr || !version) { + result.errors.push( + `${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`, + ); + continue; + } + + const { error: updateErr } = await supabase + .from("skills") + .update({ + current_version_id: version.id, + status: "active", + updated_at: new Date().toISOString(), + }) + .eq("id", skill.id); + + if (updateErr) { + result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`); + continue; + } + + result.created_count += 1; + } + + return result; +} diff --git a/supabase/functions/create-cronjob-from-agent/index.ts b/supabase/functions/create-cronjob-from-agent/index.ts index e58a067..1641939 100644 --- a/supabase/functions/create-cronjob-from-agent/index.ts +++ b/supabase/functions/create-cronjob-from-agent/index.ts @@ -23,12 +23,30 @@ import cronstrue from "https://esm.sh/cronstrue@2.50.0/i18n"; import { corsHeaders } from "../_shared/cors.ts"; import { syncAgentRuntimeSnapshot } from "../_shared/runtime-sync.ts"; +type GenericTable = { + Row: Record; + Insert: Record; + Update: Record; + Relationships: []; +}; + +type GenericDatabase = { + public: { + Tables: Record; + Views: Record; + Functions: Record; Returns: unknown }>; + }; +}; + +type SupabaseAdminClient = ReturnType>; + const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; const INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? ""; const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; +const CONTRACT_VERSION = "2026-05-28"; const MODEL = "google/gemini-2.5-flash"; @@ -66,6 +84,13 @@ function constantTimeEq(a: string, b: string): boolean { return diff === 0; } +function isAuthorized(req: Request): boolean { + const received = req.headers.get("x-internal-secret") ?? ""; + return ( + !!INTERNAL_FUNCTION_SECRET && !!received && constantTimeEq(INTERNAL_FUNCTION_SECRET, received) + ); +} + function buildSystemPrompt(tz: string): string { return `Você é um parser de descrições de cronjobs em português para o assistente Mika. Receba uma descrição em linguagem natural e retorne APENAS JSON válido, sem markdown, sem explicações. @@ -90,12 +115,16 @@ interface ParsedJob { function tryParseJson(text: string): ParsedJob | null { try { return JSON.parse(text) as ParsedJob; - } catch (_) { /* segue */ } + } catch (_) { + /* segue */ + } const match = text.match(/\{[\s\S]*\}/); if (match) { try { return JSON.parse(match[0]) as ParsedJob; - } catch (_) { /* falhou */ } + } catch (_) { + /* falhou */ + } } return null; } @@ -123,16 +152,56 @@ async function parseWithAI(input: string, tz: string): Promise return tryParseJson(content); } +async function markJobRuntimeSyncError( + admin: SupabaseAdminClient, + jobId: string, + syncError: string, +): Promise { + const message = syncError.slice(0, 2000); + const { error } = await admin + .from("scheduled_jobs") + .update({ + status: "error", + auto_paused_reason: "Falha ao sincronizar esta automação com o runtime do agente.", + runtime_state: "error", + runtime_last_status: "error", + runtime_last_error: message, + }) + .eq("id", jobId); + + if (error) { + console.error("failed to mark scheduled_job runtime sync error:", error); + } +} + Deno.serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); - if (req.method !== "POST") return json({ error: "method not allowed" }, 405); // 1) Auth: apenas X-Internal-Secret (chamada server-to-server do runtime) - const received = req.headers.get("x-internal-secret") ?? ""; - if (!INTERNAL_FUNCTION_SECRET || !received || !constantTimeEq(INTERNAL_FUNCTION_SECRET, received)) { + if (!isAuthorized(req)) { return json({ error: "unauthorized" }, 401); } + if (req.method === "GET" || req.method === "HEAD") { + return json({ + success: true, + endpoint: "create-cronjob-from-agent", + contract_version: CONTRACT_VERSION, + expected_header: "X-Internal-Secret", + required_body_fields: ["agent_instance_id", "natural_language_input"], + optional_body_fields: [ + "cron_expression", + "action_prompt", + "required_mcp_slugs", + "name", + "description", + "timezone", + ], + }); + } + + if (req.method !== "POST") return json({ error: "method not allowed" }, 405); + // 2) Body let body: RequestBody; try { @@ -144,7 +213,8 @@ Deno.serve(async (req) => { if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400); const input = (body.natural_language_input ?? "").trim(); if (input.length < 5) return json({ error: "natural_language_input too short" }, 400); - if (input.length > 1000) return json({ error: "natural_language_input too long (max 1000)" }, 400); + if (input.length > 1000) + return json({ error: "natural_language_input too long (max 1000)" }, 400); const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, @@ -172,7 +242,9 @@ Deno.serve(async (req) => { let cron = (body.cron_expression ?? "").trim(); let actionPrompt = (body.action_prompt ?? "").trim(); let reqSlugs = Array.isArray(body.required_mcp_slugs) - ? body.required_mcp_slugs.filter((s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s)) + ? body.required_mcp_slugs.filter( + (s): s is string => typeof s === "string" && VALID_MCP_SLUGS.has(s), + ) : []; if (!cron || !actionPrompt) { @@ -208,7 +280,9 @@ Deno.serve(async (req) => { let humanReadable = cron; try { humanReadable = cronstrue.toString(cron, { locale: "pt_BR" }); - } catch (_) { /* fallback */ } + } catch (_) { + /* fallback */ + } // 7) Nome: usa o fornecido ou deriva do input const name = (body.name ?? "").trim() || input.slice(0, 80); @@ -245,7 +319,8 @@ Deno.serve(async (req) => { return json({ error: "failed to create cronjob", detail: insertErr.message }, 500); } - // 9) Push para o runtime (best-effort; não derruba a criação se falhar) + // 9) Push para o runtime. Se falhar, o job fica registrado como erro, + // mas não fica ativo na UI como se estivesse realmente agendado. let syncOk = false; let syncError: string | null = null; try { @@ -259,7 +334,23 @@ Deno.serve(async (req) => { syncOk = true; } catch (e) { syncError = e instanceof Error ? e.message : String(e); - console.error("runtime sync failed (job created anyway):", syncError); + console.error("runtime sync failed; marking job as error:", syncError); + await markJobRuntimeSyncError(admin, inserted.id, syncError); + return json( + { + success: false, + job_id: inserted.id, + name: inserted.name, + cron_expression: inserted.cron_expression, + human_readable: inserted.human_readable, + next_run_at: inserted.next_run_at, + required_mcp_slugs: inserted.required_mcp_slugs, + status: "error", + runtime_sync_ok: false, + runtime_sync_error: syncError, + }, + 502, + ); } return json({ @@ -270,6 +361,7 @@ Deno.serve(async (req) => { human_readable: inserted.human_readable, next_run_at: inserted.next_run_at, required_mcp_slugs: inserted.required_mcp_slugs, + status: inserted.status, runtime_sync_ok: syncOk, runtime_sync_error: syncError, }); diff --git a/supabase/functions/create-skill-from-agent/index.ts b/supabase/functions/create-skill-from-agent/index.ts new file mode 100644 index 0000000..8c4c907 --- /dev/null +++ b/supabase/functions/create-skill-from-agent/index.ts @@ -0,0 +1,334 @@ +// create-skill-from-agent +// Endpoint server-to-server chamado pelo runtime Mika/Hermes quando o usuario +// pede pelo Telegram para criar uma nova skill. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +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 INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; +const LOVABLE_API_KEY = Deno.env.get("LOVABLE_API_KEY") ?? ""; +const RAILWAY_API_TOKEN = Deno.env.get("RAILWAY_API_TOKEN") ?? ""; +const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; +const MODEL = "google/gemini-2.5-flash"; +const CONTRACT_VERSION = "2026-05-28"; +const MAX_MARKDOWN_LEN = 50000; + +interface RequestBody { + agent_instance_id: string; + natural_language_input: string; + name?: string; + description?: string; + trigger_keywords?: string; + markdown_content?: string; +} + +interface GeneratedSkill { + name: string; + description: string; + trigger_keywords: string; + markdown_content: string; +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function constantTimeEq(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +function isAuthorized(req: Request): boolean { + const received = req.headers.get("x-internal-secret") ?? ""; + return ( + !!INTERNAL_FUNCTION_SECRET && !!received && constantTimeEq(INTERNAL_FUNCTION_SECRET, received) + ); +} + +function normalizeText(value: unknown, fallback: string, max = 200): string { + const text = typeof value === "string" ? value.trim() : ""; + return (text || fallback).slice(0, max); +} + +function extractFrontmatterField(markdown: string, field: string): string | null { + const match = markdown.match(new RegExp(`^${field}:\\s*(.+)$`, "im")); + return match?.[1]?.trim().replace(/^["']|["']$/g, "") || null; +} + +function ensureSkillFrontmatter(skill: GeneratedSkill): string { + const markdown = skill.markdown_content + .trim() + .replace(/^```(?:markdown)?\s*/i, "") + .replace(/```$/i, "") + .trim(); + const body = markdown.replace(/^---\s*[\s\S]*?\n---\s*/i, "").trim(); + + return `--- +name: ${skill.name} +description: ${skill.description} +trigger_keywords: ${skill.trigger_keywords} +--- + +${body}`.slice(0, MAX_MARKDOWN_LEN); +} + +function tryParseJson(text: string): GeneratedSkill | null { + try { + return JSON.parse(text) as GeneratedSkill; + } catch (_) { + // segue + } + const match = text.match(/\{[\s\S]*\}/); + if (!match) return null; + try { + return JSON.parse(match[0]) as GeneratedSkill; + } catch (_) { + return null; + } +} + +async function generateSkillFromInput(input: string): Promise { + if (!LOVABLE_API_KEY) return null; + + const systemPrompt = `Voce cria skills para o Hermes Agent no padrao agentskills.io. +Retorne APENAS JSON valido, sem markdown fence e sem comentarios. +Formato: +{"name":"Nome curto","description":"Descricao curta","trigger_keywords":"palavra, sinonimo, frase","markdown_content":"---\\nname: ...\\ndescription: ...\\ntrigger_keywords: ...\\n---\\n\\n## Quando usar\\n...\\n\\n## Inputs esperados\\n...\\n\\n## Passo a passo\\n1. ...\\n\\n## Ferramentas necessarias\\n...\\n\\n## Criterio de sucesso\\n..."} +Use portugues brasileiro, comandos claros, e nao prometa executar ferramentas que nao foram citadas ou conectadas.`; + + const res = 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, + max_tokens: 1800, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: input }, + ], + }), + }); + + if (!res.ok) { + console.error("create-skill-from-agent AI gateway status:", res.status); + return null; + } + + const data = await res.json(); + const content: string = data?.choices?.[0]?.message?.content ?? ""; + return tryParseJson(content); +} + +function buildFormInputs(skill: GeneratedSkill, originalInput: string): Record { + return { + name: skill.name, + description: skill.description, + trigger_keywords: skill.trigger_keywords, + expected_inputs: originalInput, + steps: "Criada via Hermes a partir de instrucao em linguagem natural.", + required_tools: [], + success_criteria: "A skill possui gatilhos claros, passos executaveis e criterio de sucesso.", + example_use_case: originalInput, + }; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + if (!isAuthorized(req)) { + return json({ error: "unauthorized" }, 401); + } + + if (req.method === "GET" || req.method === "HEAD") { + return json({ + success: true, + endpoint: "create-skill-from-agent", + contract_version: CONTRACT_VERSION, + expected_header: "X-Internal-Secret", + required_body_fields: ["agent_instance_id", "natural_language_input"], + optional_body_fields: ["name", "description", "trigger_keywords", "markdown_content"], + }); + } + + if (req.method !== "POST") return json({ error: "method not allowed" }, 405); + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return json({ error: "invalid json body" }, 400); + } + + if (!body.agent_instance_id) return json({ error: "agent_instance_id required" }, 400); + const input = (body.natural_language_input ?? "").trim(); + if (input.length < 10) return json({ error: "natural_language_input too short" }, 400); + if (input.length > 3000) + return json({ error: "natural_language_input too long (max 3000)" }, 400); + + const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: agent, error: agentErr } = await admin + .from("agent_instances") + .select("id, user_id") + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (agentErr || !agent) return json({ error: "agent_instance not found" }, 404); + + let generated: GeneratedSkill | null = null; + if (body.markdown_content?.trim()) { + const markdown = body.markdown_content.trim(); + generated = { + name: normalizeText( + body.name ?? extractFrontmatterField(markdown, "name"), + "Skill criada pelo Hermes", + 80, + ), + description: normalizeText( + body.description ?? extractFrontmatterField(markdown, "description"), + "Skill criada via Hermes.", + 240, + ), + trigger_keywords: normalizeText( + body.trigger_keywords ?? extractFrontmatterField(markdown, "trigger_keywords"), + input.slice(0, 120), + 240, + ), + markdown_content: markdown, + }; + } else { + generated = await generateSkillFromInput(input); + } + + if (!generated?.markdown_content?.trim()) { + return json({ error: "failed to generate skill content" }, 422); + } + + const skillName = normalizeText(body.name ?? generated.name, "Skill criada pelo Hermes", 80); + const skillDescription = normalizeText( + body.description ?? generated.description, + "Skill criada via Hermes.", + 240, + ); + const skillTriggerKeywords = normalizeText( + body.trigger_keywords ?? generated.trigger_keywords, + input.slice(0, 120), + 240, + ); + const skill: GeneratedSkill = { + name: skillName, + description: skillDescription, + trigger_keywords: skillTriggerKeywords, + markdown_content: ensureSkillFrontmatter({ + name: skillName, + description: skillDescription, + trigger_keywords: skillTriggerKeywords, + markdown_content: generated.markdown_content, + }), + }; + + const { data: insertedSkill, error: skillErr } = await admin + .from("skills") + .insert({ + user_id: agent.user_id, + agent_instance_id: agent.id, + name: skill.name, + description: skill.description, + trigger_keywords: skill.trigger_keywords, + status: "draft", + }) + .select("id") + .single(); + + if (skillErr || !insertedSkill) { + const code = (skillErr as { code?: string } | null)?.code ?? ""; + if (code === "P0001") return json({ error: "no active subscription" }, 402); + if (code === "P0002") return json({ error: "skill limit reached for plan" }, 403); + if (code === "23505") return json({ error: "skill name already exists" }, 409); + console.error("create-skill-from-agent insert skill failed:", skillErr); + return json({ error: "failed to create skill", detail: skillErr?.message }, 500); + } + + const { data: version, error: versionErr } = await admin + .from("skill_versions") + .insert({ + skill_id: insertedSkill.id, + version_number: 1, + markdown_content: skill.markdown_content, + form_inputs: buildFormInputs(skill, input), + is_live: true, + created_by: agent.user_id, + }) + .select("id") + .single(); + + if (versionErr || !version) { + await admin.from("skills").update({ status: "archived" }).eq("id", insertedSkill.id); + console.error("create-skill-from-agent insert version failed:", versionErr); + return json({ error: "failed to create skill version", detail: versionErr?.message }, 500); + } + + const { error: publishErr } = await admin + .from("skills") + .update({ + current_version_id: version.id, + status: "active", + updated_at: new Date().toISOString(), + }) + .eq("id", insertedSkill.id); + + if (publishErr) { + console.error("create-skill-from-agent publish failed:", publishErr); + return json({ error: "failed to publish skill", detail: publishErr.message }, 500); + } + + try { + const syncResult = await syncAgentSkillsSnapshot({ + supabase: admin, + agentInstanceId: agent.id, + railwayToken: RAILWAY_API_TOKEN, + apiKey: HERMES_API_SERVER_KEY, + }); + + return json({ + success: true, + skill_id: insertedSkill.id, + skill_version_id: version.id, + name: skill.name, + description: skill.description, + trigger_keywords: skill.trigger_keywords, + status: "active", + runtime_sync_ok: true, + synced_count: syncResult.synced_count, + }); + } catch (e) { + const syncError = e instanceof Error ? e.message : String(e); + await admin.from("skills").update({ status: "testing" }).eq("id", insertedSkill.id); + console.error("create-skill-from-agent skills sync failed:", syncError); + return json( + { + success: false, + skill_id: insertedSkill.id, + skill_version_id: version.id, + name: skill.name, + status: "testing", + runtime_sync_ok: false, + runtime_sync_error: syncError, + }, + 502, + ); + } +}); diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index d41983b..cecb751 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -22,6 +22,23 @@ import { normalizeOllamaModelSelection, } from "../_shared/hermes-config.ts"; +type GenericTable = { + Row: Record; + Insert: Record; + Update: Record; + Relationships: []; +}; + +type GenericDatabase = { + public: { + Tables: Record; + Views: Record; + Functions: Record; Returns: unknown }>; + }; +}; + +type SupabaseAdminClient = ReturnType>; + interface RequestBody { agent_instance_id: string; agent_name?: string; @@ -31,28 +48,77 @@ interface RequestBody { tts_provider?: string; } +interface SubscriptionWithPlan { + plans?: { slug?: string | null } | { slug?: string | null }[] | null; +} + +interface AgentInstanceRow { + id: string; + user_id: string; + uuid_tenant: string; + status: string; + telegram_bot_token_vault_id?: string | null; + telegram_bot_username?: string | null; + telegram_user_chat_id?: string | number | null; + railway_service_id?: string | null; + agent_name?: string | null; + vps_pool_id?: string | null; +} + +function getPlanSlug(subscription: SubscriptionWithPlan | null): string { + const plans = subscription?.plans; + const plan = Array.isArray(plans) ? plans[0] : plans; + return plan?.slug ?? "basic"; +} + 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 INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; +const HERMES_RUNTIME_IMAGE = + Deno.env.get("HERMES_RUNTIME_IMAGE") ?? "ghcr.io/domfelipe/hermes-agent-custom:latest"; +const MIKA_RUNTIME_CONTRACT_VERSION = "2026-05-28"; const ADMIN_TELEGRAM_BOT_TOKEN = Deno.env.get("ADMIN_TELEGRAM_BOT_TOKEN"); const ADMIN_TELEGRAM_CHAT_ID = Deno.env.get("ADMIN_TELEGRAM_CHAT_ID"); +function buildRuntimePlatformEnv(agentInstanceId: string): Record { + const functionsBaseUrl = `${SUPABASE_URL.replace(/\/$/, "")}/functions/v1`; + const createCronjobUrl = `${functionsBaseUrl}/create-cronjob-from-agent`; + const createSkillUrl = `${functionsBaseUrl}/create-skill-from-agent`; + + return { + AGENT_INSTANCE_ID: agentInstanceId, + HERMES_AGENT_INSTANCE_ID: agentInstanceId, + HERMES_CREATE_CRONJOB_URL: createCronjobUrl, + HERMES_CREATE_SKILL_URL: createSkillUrl, + HERMES_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + HERMES_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + HERMES_RUNTIME_CONTRACT_VERSION: MIKA_RUNTIME_CONTRACT_VERSION, + INTERNAL_FUNCTION_SECRET, + MIKA_AGENT_INSTANCE_ID: agentInstanceId, + MIKA_CREATE_CRONJOB_URL: createCronjobUrl, + MIKA_CREATE_SKILL_URL: createSkillUrl, + MIKA_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + MIKA_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + MIKA_RUNTIME_CONTRACT_VERSION, + SUPABASE_URL, + }; +} + async function notifyAdmin(message: string): Promise { if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; try { - await fetch( - `https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chat_id: ADMIN_TELEGRAM_CHAT_ID, - text: message, - parse_mode: "HTML", - }), - }, - ); + await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: ADMIN_TELEGRAM_CHAT_ID, + text: message, + parse_mode: "HTML", + }), + }); } catch (e) { console.error("notifyAdmin failed:", e); } @@ -93,7 +159,7 @@ Deno.serve(async (req) => { const { data: agent, error: agentErr } = await supabase .from("agent_instances") .select( - "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, agent_name", + "id, user_id, uuid_tenant, status, telegram_bot_token_vault_id, telegram_bot_username, telegram_user_chat_id, railway_service_id, agent_name, vps_pool_id", ) .eq("id", body.agent_instance_id) .maybeSingle(); @@ -105,29 +171,33 @@ Deno.serve(async (req) => { if (agent.status !== "provisioning") { console.log(`[provision-agent] status atual=${agent.status}, abortando`); - return jsonResponse(409, { error: "agent_instance is not in provisioning status", status: agent.status }); + return jsonResponse(409, { + error: "agent_instance is not in provisioning status", + status: agent.status, + }); } // Se já existe railway_service_id → fluxo de UPDATE (não tenta criar novo serviço) if (agent.railway_service_id) { - console.log(`[provision-agent] railway_service_id já existe (${agent.railway_service_id}) → modo update`); + console.log( + `[provision-agent] railway_service_id já existe (${agent.railway_service_id}) → modo update`, + ); return await handleUpdateExistingService(supabase, agent, body); } // 1b) Carregar profile (full_name → nome do agente) - const { data: profile } = await supabase + const { data: profileData } = await supabase .from("profiles") .select("full_name") .eq("id", agent.user_id) .maybeSingle(); + const profile = profileData as { full_name?: string | null } | null; const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const firstName = fullName.split(" ")[0] || "Usuário"; // Prioridade: body > coluna agent_name no DB > default "Mika de {firstName}" const agentName = - body.agent_name?.trim() || - (agent.agent_name?.trim() ?? "") || - `Mika de ${firstName}`; + body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`; console.log(`[provision-agent] profile carregado: ${fullName} → agent_name=${agentName}`); // 1c) Carregar subscription ativa (para definir modelo Pro vs Basic) @@ -140,8 +210,7 @@ Deno.serve(async (req) => { .limit(1) .maybeSingle(); - // deno-lint-ignore no-explicit-any - const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic"; + const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null); console.log(`[provision-agent] plano=${planSlug}`); // 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade) @@ -166,7 +235,9 @@ Deno.serve(async (req) => { ); return jsonResponse(503, { error: "no railway pool available" }); } - console.log(`[provision-agent] pool selecionado: ${pool.id} (railway_project=${pool.railway_project_id})`); + console.log( + `[provision-agent] pool selecionado: ${pool.id} (railway_project=${pool.railway_project_id})`, + ); // 3) Criar provisioning_job em status running const { data: job, error: jobErr } = await supabase @@ -190,14 +261,19 @@ Deno.serve(async (req) => { if (jobErr || !job) { console.error(`[provision-agent] falha ao criar job: ${jobErr?.message}`); - return jsonResponse(500, { error: "failed to create provisioning_job", detail: jobErr?.message }); + return jsonResponse(500, { + error: "failed to create provisioning_job", + detail: jobErr?.message, + }); } console.log(`[provision-agent] provisioning_job criado: ${job.id}`); // 4) Decrypt do telegram_bot_token (se existir) let telegramBotToken = ""; if (agent.telegram_bot_token_vault_id) { - console.log(`[provision-agent] decifrando token do Vault: ${agent.telegram_bot_token_vault_id}`); + console.log( + `[provision-agent] decifrando token do Vault: ${agent.telegram_bot_token_vault_id}`, + ); const { data: secret } = await supabase.rpc("vault_decrypt_secret", { secret_id: agent.telegram_bot_token_vault_id, }); @@ -240,9 +316,10 @@ Deno.serve(async (req) => { const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); const envVars: Record = { + ...buildRuntimePlatformEnv(agent.id), HERMES_HOME: "/opt/data/.hermes", API_SERVER_ENABLED: "true", - API_SERVER_KEY: Deno.env.get("HERMES_API_SERVER_KEY") ?? "", + API_SERVER_KEY: HERMES_API_SERVER_KEY, GATEWAY_ALLOW_ALL_USERS: "false", HERMES_MODEL_DEFAULT: modelFinal, HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, @@ -275,7 +352,9 @@ Deno.serve(async (req) => { const msg = createErr instanceof Error ? createErr.message : String(createErr); // Recover from "service already exists" — provavelmente sobra de attempt anterior if (msg.includes("already exists")) { - console.warn(`[provision-agent] serviço já existe, tentando recuperar ID por nome: ${serviceName}`); + console.warn( + `[provision-agent] serviço já existe, tentando recuperar ID por nome: ${serviceName}`, + ); const existingId = await findRailwayServiceByName({ token: RAILWAY_API_TOKEN, projectId: pool.railway_project_id, @@ -296,11 +375,13 @@ Deno.serve(async (req) => { serviceId: railwayServiceId, environmentId: pool.railway_environment_id, projectId: pool.railway_project_id, - image: "ghcr.io/domfelipe/hermes-agent-custom:latest", + image: HERMES_RUNTIME_IMAGE, variables: envVars, startCommand: HERMES_START_COMMAND, }); - console.log(`[provision-agent] serviço configurado com ${Object.keys(envVars).length} env vars`); + console.log( + `[provision-agent] serviço configurado com ${Object.keys(envVars).length} env vars`, + ); await deployRailwayService({ token: RAILWAY_API_TOKEN, @@ -346,7 +427,9 @@ Deno.serve(async (req) => { .update({ railway_service_id: railwayServiceId, status: "running" }) .eq("id", job.id); - console.log(`[provision-agent] sucesso: agent=${agent.id} railway=${railwayServiceId} (aguardando deploy)`); + console.log( + `[provision-agent] sucesso: agent=${agent.id} railway=${railwayServiceId} (aguardando deploy)`, + ); // status do agent permanece 'provisioning' — railway-webhook atualiza para 'active' quando deploy subir return jsonResponse(200, { @@ -367,8 +450,7 @@ function jsonResponse(status: number, body: unknown) { } async function failJob( - // deno-lint-ignore no-explicit-any - supabase: any, + supabase: SupabaseAdminClient, agent: { id: string }, jobId: string | null, message: string, @@ -383,8 +465,7 @@ async function failJob( } async function scheduleRetry( - // deno-lint-ignore no-explicit-any - supabase: any, + supabase: SupabaseAdminClient, agent: { id: string }, jobId: string, message: string, @@ -424,27 +505,27 @@ async function scheduleRetry( * faz upsert das variáveis de ambiente com defaults automáticos e dispara redeploy. */ async function handleUpdateExistingService( - // deno-lint-ignore no-explicit-any - supabase: any, - // deno-lint-ignore no-explicit-any - agent: any, + supabase: SupabaseAdminClient, + agent: AgentInstanceRow, body: RequestBody, ): Promise { - const railwayServiceId: string = agent.railway_service_id; + const railwayServiceId = agent.railway_service_id; + if (!railwayServiceId) { + return jsonResponse(400, { error: "railway_service_id required for update mode" }); + } // Carregar profile + plano para gerar defaults coerentes - const { data: profile } = await supabase + const { data: profileData } = await supabase .from("profiles") .select("full_name") .eq("id", agent.user_id) .maybeSingle(); + const profile = profileData as { full_name?: string | null } | null; const fullName = (profile?.full_name?.trim() || "Usuário").toString(); const firstName = fullName.split(" ")[0] || "Usuário"; const agentName = - body.agent_name?.trim() || - (agent.agent_name?.trim() ?? "") || - `Mika de ${firstName}`; + body.agent_name?.trim() || (agent.agent_name?.trim() ?? "") || `Mika de ${firstName}`; const { data: subscription } = await supabase .from("subscriptions") @@ -455,8 +536,7 @@ async function handleUpdateExistingService( .limit(1) .maybeSingle(); - // deno-lint-ignore no-explicit-any - const planSlug = ((subscription as any)?.plans?.slug as string | undefined) ?? "basic"; + const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null); const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`; const soulContent = body.soul_content?.trim() || defaultSoul; @@ -464,18 +544,24 @@ async function handleUpdateExistingService( const sttProvider = body.stt_provider || "local"; const ttsProvider = body.tts_provider || "disabled"; - console.log(`[provision-agent:update] agent=${agent.id} service=${railwayServiceId} plano=${planSlug}`); + console.log( + `[provision-agent:update] agent=${agent.id} service=${railwayServiceId} plano=${planSlug}`, + ); // Resolver project/environment Railway let projectId: string | null = null; let environmentId: string | null = null; if (agent.vps_pool_id) { - const { data: pool } = await supabase + const { data: poolData } = await supabase .from("vps_pool") .select("railway_project_id, railway_environment_id") .eq("id", agent.vps_pool_id) .maybeSingle(); + const pool = poolData as { + railway_project_id?: string | null; + railway_environment_id?: string | null; + } | null; projectId = pool?.railway_project_id ?? null; environmentId = pool?.railway_environment_id ?? null; } @@ -492,6 +578,11 @@ async function handleUpdateExistingService( } const variables: Record = { + ...buildRuntimePlatformEnv(agent.id), + API_SERVER_ENABLED: "true", + API_SERVER_KEY: HERMES_API_SERVER_KEY, + GATEWAY_ALLOW_ALL_USERS: "false", + HERMES_HOME: "/opt/data/.hermes", HERMES_MODEL_DEFAULT: model, HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, HERMES_SOUL_OVERRIDE: soulContent, @@ -517,14 +608,16 @@ async function handleUpdateExistingService( variables, skipDeploys: true, }); - console.log(`[provision-agent:update] variáveis atualizadas (${Object.keys(variables).length})`); + console.log( + `[provision-agent:update] variáveis atualizadas (${Object.keys(variables).length})`, + ); await configureRailwayService({ token: RAILWAY_API_TOKEN!, serviceId: railwayServiceId, environmentId, projectId, - image: "ghcr.io/domfelipe/hermes-agent-custom:latest", + image: HERMES_RUNTIME_IMAGE, variables: {}, startCommand: HERMES_START_COMMAND, }); diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts index 1cab649..5613fb3 100644 --- a/supabase/functions/railway-webhook/index.ts +++ b/supabase/functions/railway-webhook/index.ts @@ -10,10 +10,33 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; import { corsHeaders } from "../_shared/cors.ts"; -import { - syncAgentRuntimeSnapshot, - syncAgentSkillsSnapshot, -} from "../_shared/runtime-sync.ts"; +import { syncAgentRuntimeSnapshot, syncAgentSkillsSnapshot } from "../_shared/runtime-sync.ts"; +import { ensureDefaultSkillsForAgent } from "../_shared/default-skills.ts"; + +type GenericTable = { + Row: Record; + Insert: Record; + Update: Record; + Relationships: []; +}; + +type GenericDatabase = { + public: { + Tables: Record; + Views: Record; + Functions: Record; Returns: unknown }>; + }; +}; + +type SupabaseAdminClient = ReturnType>; + +interface AgentWelcomeRow { + id: string; + user_id: string; + telegram_bot_token_vault_id: string; + telegram_user_chat_id: string | number; + agent_name?: string | null; +} const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; @@ -25,18 +48,15 @@ const HERMES_API_SERVER_KEY = Deno.env.get("HERMES_API_SERVER_KEY") ?? ""; async function notifyAdmin(message: string): Promise { if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; try { - await fetch( - `https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chat_id: ADMIN_TELEGRAM_CHAT_ID, - text: message, - parse_mode: "HTML", - }), - }, - ); + await fetch(`https://api.telegram.org/bot${ADMIN_TELEGRAM_BOT_TOKEN}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: ADMIN_TELEGRAM_CHAT_ID, + text: message, + parse_mode: "HTML", + }), + }); } catch (e) { console.error("notifyAdmin failed:", e); } @@ -64,7 +84,9 @@ Deno.serve(async (req) => { ["sign"], ); const macBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody)); - const macHex = Array.from(new Uint8Array(macBuf)).map((b) => b.toString(16).padStart(2, "0")).join(""); + const macHex = Array.from(new Uint8Array(macBuf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); const expected = sig.startsWith("sha256=") ? sig.slice(7) : sig; if (expected !== macHex) { console.warn("railway-webhook: invalid signature"); @@ -188,7 +210,10 @@ Deno.serve(async (req) => { ); } catch (e) { runtimeSyncError = e instanceof Error ? e.message : String(e); - console.error(`railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`, runtimeSyncError); + console.error( + `railway-webhook: falha ao sincronizar runtime do agent ${agent.id}:`, + runtimeSyncError, + ); if (wasProvisioning) { const fullName = await loadFullName(); await notifyAdmin( @@ -201,6 +226,33 @@ Deno.serve(async (req) => { } } + let defaultSkillsError: string | null = null; + try { + const defaultSkillsResult = await ensureDefaultSkillsForAgent(supabase, agent.id); + if (defaultSkillsResult.errors.length > 0) { + throw new Error(defaultSkillsResult.errors.join("; ")); + } + console.log( + `railway-webhook: default skills agent ${agent.id} (${defaultSkillsResult.created_count} criadas, ${defaultSkillsResult.skipped_count} existentes)`, + ); + } catch (e) { + defaultSkillsError = e instanceof Error ? e.message : String(e); + console.error( + `railway-webhook: falha ao garantir skills padrão do agent ${agent.id}:`, + defaultSkillsError, + ); + if (wasProvisioning) { + const fullName = await loadFullName(); + await notifyAdmin( + `⚠️ Agente subiu, mas as skills padrão falharam\n\n` + + `👤 Cliente: ${fullName}\n` + + `🚀 Railway: ${agent.railway_service_id}\n` + + `❗ Erro: ${defaultSkillsError}\n\n` + + `➡️ Revisar no admin`, + ); + } + } + let skillsSyncError: string | null = null; try { const syncResult = await syncAgentSkillsSnapshot({ @@ -214,7 +266,10 @@ Deno.serve(async (req) => { ); } catch (e) { skillsSyncError = e instanceof Error ? e.message : String(e); - console.error(`railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`, skillsSyncError); + console.error( + `railway-webhook: falha ao sincronizar skills do agent ${agent.id}:`, + skillsSyncError, + ); if (wasProvisioning) { const fullName = await loadFullName(); await notifyAdmin( @@ -228,7 +283,7 @@ Deno.serve(async (req) => { } // Notifica admin somente se era um auto-provisionamento (status anterior=provisioning) - if (wasProvisioning && !skillsSyncError && !runtimeSyncError) { + if (wasProvisioning && !skillsSyncError && !runtimeSyncError && !defaultSkillsError) { const fullName = await loadFullName(); await notifyAdmin( `✅ Agente provisionado automaticamente!\n\n` + @@ -243,10 +298,7 @@ Deno.serve(async (req) => { } if (upper === "FAILED" || upper === "CRASHED") { - await supabase - .from("agent_instances") - .update({ status: "error" }) - .eq("id", agent.id); + await supabase.from("agent_instances").update({ status: "error" }).eq("id", agent.id); await supabase .from("provisioning_jobs") @@ -286,12 +338,15 @@ function jsonResponse(status: number, body: unknown) { }); } -// deno-lint-ignore no-explicit-any -async function sendWelcomeMessage(supabase: any, agent: any): Promise { +async function sendWelcomeMessage( + supabase: SupabaseAdminClient, + agent: AgentWelcomeRow, +): Promise { // Decifra o token do bot - const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + const { data: secretData } = await supabase.rpc("vault_decrypt_secret", { secret_id: agent.telegram_bot_token_vault_id, }); + const secret = secretData as { decrypted_secret?: string | null }[] | null; const token: string = secret?.[0]?.decrypted_secret ?? ""; if (!token) { console.warn("sendWelcomeMessage: token vazio, abortando"); From ddac76bec83429dad4c18ae33cf553758e822b1a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:02:18 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- supabase/functions/provision-agent/index.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts index cecb751..fa78d04 100644 --- a/supabase/functions/provision-agent/index.ts +++ b/supabase/functions/provision-agent/index.ts @@ -107,6 +107,18 @@ function buildRuntimePlatformEnv(agentInstanceId: string): Record { if (!ADMIN_TELEGRAM_BOT_TOKEN || !ADMIN_TELEGRAM_CHAT_ID) return; try { @@ -311,7 +323,7 @@ Deno.serve(async (req) => { const chatIdStr = hasChatId ? String(agent.telegram_user_chat_id) : ""; const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DomCo. exclusivamente para ${fullName}. Seu estilo: Direto e objetivo, sempre em português brasileiro, respostas curtas no Telegram, use emojis com moderação, trate ${firstName} pelo primeiro nome. Suas prioridades: produtividade, automação proativa. Identidade: você é ${agentName} da DomCo., nunca se identifique como Hermes ou qualquer outro modelo.`; - const soulContent = body.soul_content?.trim() || defaultSoul; + const soulContent = withRuntimeActionInstructions(body.soul_content?.trim() || defaultSoul); const modelFinal = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); @@ -320,6 +332,7 @@ Deno.serve(async (req) => { HERMES_HOME: "/opt/data/.hermes", API_SERVER_ENABLED: "true", API_SERVER_KEY: HERMES_API_SERVER_KEY, + API_SERVER_PORT: "8765", GATEWAY_ALLOW_ALL_USERS: "false", HERMES_MODEL_DEFAULT: modelFinal, HERMES_MODEL_PROVIDER: DEFAULT_OLLAMA_PROVIDER, @@ -538,7 +551,7 @@ async function handleUpdateExistingService( const planSlug = getPlanSlug(subscription as SubscriptionWithPlan | null); const defaultSoul = `Você se chama ${agentName}. Você é um assistente pessoal de IA criado pela DOMCO para ${fullName}. Você é proativo, direto e fala sempre em português brasileiro. Você ajuda ${firstName} a ser mais produtivo — gerenciando emails, agenda, tarefas e automatizando o que puder. Seja conciso nas respostas via Telegram. Nunca se identifique como Hermes ou como produto da Nous Research — você é Mika.`; - const soulContent = body.soul_content?.trim() || defaultSoul; + const soulContent = withRuntimeActionInstructions(body.soul_content?.trim() || defaultSoul); const model = normalizeOllamaModelSelection(body.model || DEFAULT_OLLAMA_MODEL); const sttProvider = body.stt_provider || "local"; @@ -581,6 +594,7 @@ async function handleUpdateExistingService( ...buildRuntimePlatformEnv(agent.id), API_SERVER_ENABLED: "true", API_SERVER_KEY: HERMES_API_SERVER_KEY, + API_SERVER_PORT: "8765", GATEWAY_ALLOW_ALL_USERS: "false", HERMES_HOME: "/opt/data/.hermes", HERMES_MODEL_DEFAULT: model, From ece8beae6773a2d3383352947314f462d6f004d7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:02:26 +0000 Subject: [PATCH 3/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- src/routeTree.gen.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0bb5aca..0fe84b5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -670,12 +670,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} From cc1347fbd51bcb056dd5b928a02c3b3a70d44323 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:06:25 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- src/routeTree.gen.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0fe84b5..0bb5aca 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -670,3 +670,12 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} From a0d1cf9c2b549dd54158afa40485db7cf201fe5a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:06:44 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- src/routeTree.gen.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0bb5aca..0fe84b5 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -670,12 +670,3 @@ const rootRouteChildren: RootRouteChildren = { export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) ._addFileTypes() - -import type { getRouter } from './router.tsx' -import type { createStart } from '@tanstack/react-start' -declare module '@tanstack/react-start' { - interface Register { - ssr: true - router: Awaited> - } -} From 00d6d57c2fd51ae3f30f5d3c1fcaeaa637ab33ba Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:07:13 +0000 Subject: [PATCH 6/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- .../functions/admin-backfill-runtime/index.ts | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 supabase/functions/admin-backfill-runtime/index.ts diff --git a/supabase/functions/admin-backfill-runtime/index.ts b/supabase/functions/admin-backfill-runtime/index.ts new file mode 100644 index 0000000..587ea07 --- /dev/null +++ b/supabase/functions/admin-backfill-runtime/index.ts @@ -0,0 +1,212 @@ +// admin-backfill-runtime +// Admin-only. Para cada agent_instance com railway_service_id, reaplica: +// - HERMES_RUNTIME_IMAGE (imagem corrigida) +// - API_SERVER_PORT=8765 +// - envs MIKA_* / HERMES_* obrigatórias do contrato runtime +// e dispara redeploy no Railway. NÃO recria serviço, NÃO toca tokens sensíveis +// (TELEGRAM_BOT_TOKEN, OLLAMA_API_KEY etc). Sempre roda em modo dry_run por padrão. +// +// POST body: +// { dry_run?: boolean (default true), agent_instance_id?: string } +// +// Resposta: relatório por instância com status ok|skipped|error. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { authorizeInternalRequest } from "../_shared/internal-auth.ts"; +import { + HERMES_START_COMMAND, + configureRailwayService, + deployRailwayService, + getServiceContext, + upsertRailwayVariableCollection, +} from "../_shared/railway.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 INTERNAL_FUNCTION_SECRET = Deno.env.get("INTERNAL_FUNCTION_SECRET") ?? ""; +const HERMES_RUNTIME_IMAGE = + Deno.env.get("HERMES_RUNTIME_IMAGE") ?? "ghcr.io/domfelipe/hermes-agent-custom:latest"; +const MIKA_RUNTIME_CONTRACT_VERSION = "2026-05-28"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +function buildBackfillEnv(agentInstanceId: string): Record { + const functionsBaseUrl = `${SUPABASE_URL.replace(/\/$/, "")}/functions/v1`; + const createCronjobUrl = `${functionsBaseUrl}/create-cronjob-from-agent`; + const createSkillUrl = `${functionsBaseUrl}/create-skill-from-agent`; + + return { + AGENT_INSTANCE_ID: agentInstanceId, + API_SERVER_PORT: "8765", + HERMES_AGENT_INSTANCE_ID: agentInstanceId, + HERMES_CREATE_CRONJOB_URL: createCronjobUrl, + HERMES_CREATE_SKILL_URL: createSkillUrl, + HERMES_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + HERMES_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + HERMES_RUNTIME_CONTRACT_VERSION: MIKA_RUNTIME_CONTRACT_VERSION, + INTERNAL_FUNCTION_SECRET, + MIKA_AGENT_INSTANCE_ID: agentInstanceId, + MIKA_CREATE_CRONJOB_URL: createCronjobUrl, + MIKA_CREATE_SKILL_URL: createSkillUrl, + MIKA_INTERNAL_FUNCTION_SECRET: INTERNAL_FUNCTION_SECRET, + MIKA_PLATFORM_FUNCTIONS_BASE_URL: functionsBaseUrl, + MIKA_RUNTIME_CONTRACT_VERSION, + SUPABASE_URL, + }; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + const auth = await authorizeInternalRequest(req, { allowOwner: false }); + if (!auth.ok) { + return jsonResponse(401, { error: "unauthorized", reason: auth.reason }); + } + // Apenas via X-Internal-Secret OU admin JWT + if (!auth.viaSecret && !auth.isAdmin) { + return jsonResponse(403, { error: "admin only" }); + } + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: { dry_run?: boolean; agent_instance_id?: string } = {}; + try { + body = await req.json(); + } catch { + /* allow empty body */ + } + const dryRun = body.dry_run !== false; // default true + const filterId = body.agent_instance_id; + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + let query = supabase + .from("agent_instances") + .select("id, user_id, status, railway_service_id, vps_pool_id, agent_name") + .not("railway_service_id", "is", null) + .in("status", ["active", "provisioning"]); + + if (filterId) query = query.eq("id", filterId); + + const { data: agents, error: agentsErr } = await query; + if (agentsErr) { + return jsonResponse(500, { error: "failed to list agents", detail: agentsErr.message }); + } + + console.log( + `[admin-backfill-runtime] dry_run=${dryRun} target_count=${agents?.length ?? 0} image=${HERMES_RUNTIME_IMAGE}`, + ); + + const report: Array> = []; + + for (const agent of agents ?? []) { + const entry: Record = { + agent_instance_id: agent.id, + railway_service_id: agent.railway_service_id, + agent_name: agent.agent_name, + status: "pending", + }; + + try { + // Resolver project/environment + let projectId: string | null = null; + let environmentId: string | null = null; + + if (agent.vps_pool_id) { + const { data: pool } = await supabase + .from("vps_pool") + .select("railway_project_id, railway_environment_id") + .eq("id", agent.vps_pool_id) + .maybeSingle(); + projectId = (pool?.railway_project_id as string | null) ?? null; + environmentId = (pool?.railway_environment_id as string | null) ?? null; + } + + if (!projectId || !environmentId) { + const ctx = await getServiceContext({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + }); + projectId = projectId ?? ctx.projectId; + environmentId = environmentId ?? ctx.environmentId; + } + + if (!projectId || !environmentId) { + entry.status = "error"; + entry.error = "could not resolve railway project/environment"; + report.push(entry); + continue; + } + + const variables = buildBackfillEnv(agent.id); + entry.variables_count = Object.keys(variables).length; + entry.image = HERMES_RUNTIME_IMAGE; + + if (dryRun) { + entry.status = "dry_run"; + entry.would_apply = Object.keys(variables); + report.push(entry); + continue; + } + + // 1) upsert envs (sem disparar deploy ainda) + await upsertRailwayVariableCollection({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + projectId, + variables, + skipDeploys: true, + }); + + // 2) reconciliar imagem + start command + await configureRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + projectId, + image: HERMES_RUNTIME_IMAGE, + variables: {}, + startCommand: HERMES_START_COMMAND, + }); + + // 3) redeploy + await deployRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: agent.railway_service_id as string, + environmentId, + }); + + entry.status = "ok"; + } catch (e) { + entry.status = "error"; + entry.error = e instanceof Error ? e.message : String(e); + console.error(`[admin-backfill-runtime] erro em ${agent.id}:`, entry.error); + } + + report.push(entry); + } + + const summary = { + dry_run: dryRun, + image: HERMES_RUNTIME_IMAGE, + total: report.length, + ok: report.filter((r) => r.status === "ok").length, + dry: report.filter((r) => r.status === "dry_run").length, + errors: report.filter((r) => r.status === "error").length, + }; + console.log(`[admin-backfill-runtime] summary=${JSON.stringify(summary)}`); + + return jsonResponse(200, { summary, report }); +}); From bc8ea671ad8dda5be000a8d2df2f7bee08617fca Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 19:07:25 +0000 Subject: [PATCH 7/7] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- supabase/config.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/supabase/config.toml b/supabase/config.toml index cc369ce..e02a0b0 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -62,3 +62,7 @@ verify_jwt = false [functions.create-skill-from-agent] verify_jwt = false + +[functions.admin-backfill-runtime] +verify_jwt = false +