From 2b45297603d06d7232ab27e3241891cd347b7bd1 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:35:37 +0000 Subject: [PATCH] Changes Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com> --- supabase/config.toml | 6 + supabase/functions/_shared/railway.ts | 163 +++++++++++++ supabase/functions/provision-agent/index.ts | 239 ++++++++++++++++++++ supabase/functions/railway-webhook/index.ts | 116 ++++++++++ 4 files changed, 524 insertions(+) create mode 100644 supabase/functions/_shared/railway.ts create mode 100644 supabase/functions/provision-agent/index.ts create mode 100644 supabase/functions/railway-webhook/index.ts diff --git a/supabase/config.toml b/supabase/config.toml index 19fbbc2..f8e38f1 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -44,3 +44,9 @@ verify_jwt = true [functions.parse-cronjob-natural-language] verify_jwt = true + +[functions.provision-agent] +verify_jwt = false + +[functions.railway-webhook] +verify_jwt = false diff --git a/supabase/functions/_shared/railway.ts b/supabase/functions/_shared/railway.ts new file mode 100644 index 0000000..a81df2f --- /dev/null +++ b/supabase/functions/_shared/railway.ts @@ -0,0 +1,163 @@ +// Helper para chamar a Railway GraphQL API (Public API). +// Docs: https://docs.railway.com/reference/public-api +const RAILWAY_GRAPHQL = "https://backboard.railway.com/graphql/v2"; + +export interface RailwayError { + message: string; + path?: string[]; + extensions?: Record; +} + +export async function railwayQuery( + query: string, + variables: Record, + token: string, +): Promise<{ data?: T; errors?: RailwayError[] }> { + const res = await fetch(RAILWAY_GRAPHQL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Railway HTTP ${res.status}: ${text}`); + } + + return res.json(); +} + +export async function createRailwayService(opts: { + token: string; + projectId: string; + name: string; +}): Promise { + const mutation = ` + mutation ServiceCreate($input: ServiceCreateInput!) { + serviceCreate(input: $input) { id name } + } + `; + const res = await railwayQuery<{ serviceCreate: { id: string; name: string } }>( + mutation, + { input: { projectId: opts.projectId, name: opts.name } }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`serviceCreate failed: ${JSON.stringify(res.errors)}`); + } + if (!res.data?.serviceCreate?.id) { + throw new Error("serviceCreate returned no id"); + } + return res.data.serviceCreate.id; +} + +export async function configureRailwayService(opts: { + token: string; + serviceId: string; + environmentId: string; + image: string; + variables: Record; +}): Promise { + // O Railway expõe variáveis via variableUpsert (uma por vez) e fonte/imagem via serviceInstanceUpdate. + // Setamos a imagem primeiro. + const updateSource = ` + mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + } + `; + const sourceRes = await railwayQuery( + updateSource, + { + serviceId: opts.serviceId, + environmentId: opts.environmentId, + input: { source: { image: opts.image } }, + }, + opts.token, + ); + if (sourceRes.errors?.length) { + throw new Error(`serviceInstanceUpdate (source) failed: ${JSON.stringify(sourceRes.errors)}`); + } + + // Agora as variáveis. Railway recomenda variableUpsert por chave. + const variableUpsert = ` + mutation VariableUpsert($input: VariableUpsertInput!) { + variableUpsert(input: $input) + } + `; + for (const [name, value] of Object.entries(opts.variables)) { + const r = await railwayQuery( + variableUpsert, + { + input: { + projectId: undefined, // será inferido pelo serviceId+environmentId + environmentId: opts.environmentId, + serviceId: opts.serviceId, + name, + value, + }, + }, + opts.token, + ); + if (r.errors?.length) { + throw new Error(`variableUpsert(${name}) failed: ${JSON.stringify(r.errors)}`); + } + } +} + +export async function deployRailwayService(opts: { + token: string; + serviceId: string; + environmentId: string; +}): Promise { + const mutation = ` + mutation ServiceInstanceRedeploy($serviceId: String!, $environmentId: String!) { + serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId) + } + `; + const res = await railwayQuery( + mutation, + { serviceId: opts.serviceId, environmentId: opts.environmentId }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`serviceInstanceRedeploy failed: ${JSON.stringify(res.errors)}`); + } +} + +export async function setRailwayReplicas(opts: { + token: string; + serviceId: string; + environmentId: string; + replicas: number; +}): Promise { + const mutation = ` + mutation ServiceInstanceUpdate($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) { + serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input) + } + `; + const res = await railwayQuery( + mutation, + { + serviceId: opts.serviceId, + environmentId: opts.environmentId, + input: { numReplicas: opts.replicas }, + }, + opts.token, + ); + if (res.errors?.length) { + throw new Error(`setRailwayReplicas failed: ${JSON.stringify(res.errors)}`); + } +} + +/** Apaga o webhook do Telegram para que o Hermes assuma via polling. */ +export async function deleteTelegramWebhook(botToken: string): Promise { + const url = `https://api.telegram.org/bot${botToken}/deleteWebhook?drop_pending_updates=false`; + const res = await fetch(url, { method: "POST" }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`deleteWebhook failed: ${res.status} ${text}`); + } +} diff --git a/supabase/functions/provision-agent/index.ts b/supabase/functions/provision-agent/index.ts new file mode 100644 index 0000000..94b19c1 --- /dev/null +++ b/supabase/functions/provision-agent/index.ts @@ -0,0 +1,239 @@ +// provision-agent +// Cria um serviço Docker no Railway para um agent_instance que entrou em status='provisioning'. +// Chamado automaticamente pelo trigger pg_net OU manualmente pelo painel admin. +// verify_jwt = false: o trigger pg_net usa anon key como Bearer, sem JWT de usuário. + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +import { corsHeaders } from "../_shared/cors.ts"; +import { + createRailwayService, + configureRailwayService, + deployRailwayService, + deleteTelegramWebhook, +} from "../_shared/railway.ts"; + +interface RequestBody { + agent_instance_id: string; +} + +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 OPENCODE_ZEN_API_KEY = Deno.env.get("OPENCODE_ZEN_API_KEY") ?? ""; +const OPENCODE_GO_API_KEY = Deno.env.get("OPENCODE_GO_API_KEY") ?? ""; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + if (!RAILWAY_API_TOKEN) { + return jsonResponse(500, { error: "RAILWAY_API_TOKEN not configured" }); + } + + let body: RequestBody; + try { + body = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json body" }); + } + + if (!body.agent_instance_id) { + return jsonResponse(400, { error: "agent_instance_id required" }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + // 1) Carregar agent_instance + profile + 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, railway_service_id", + ) + .eq("id", body.agent_instance_id) + .maybeSingle(); + + if (agentErr || !agent) { + return jsonResponse(404, { error: "agent_instance not found", detail: agentErr?.message }); + } + + if (agent.status !== "provisioning") { + return jsonResponse(409, { error: "agent_instance is not in provisioning status", status: agent.status }); + } + + if (agent.railway_service_id) { + return jsonResponse(409, { error: "agent_instance already has a railway_service_id", railway_service_id: agent.railway_service_id }); + } + + // 2) Buscar pool disponível (com IDs Railway preenchidos e capacidade) + const { data: pool, error: poolErr } = await supabase + .from("vps_pool") + .select("id, railway_project_id, railway_environment_id, capacity_max, capacity_current") + .eq("is_active", true) + .neq("railway_project_id", "PREENCHER_APOS_CRIAR_NO_RAILWAY") + .lt("capacity_current", 10000) + .order("capacity_current", { ascending: true }) + .limit(1) + .maybeSingle(); + + if (poolErr || !pool || !pool.railway_project_id || !pool.railway_environment_id) { + await failJob(supabase, agent, null, "Nenhum vps_pool com Railway IDs configurados disponível"); + return jsonResponse(503, { error: "no railway pool available" }); + } + + // 3) Criar provisioning_job em status running + const { data: job, error: jobErr } = await supabase + .from("provisioning_jobs") + .insert({ + agent_instance_id: agent.id, + user_id: agent.user_id, + vps_pool_id: pool.id, + status: "running", + attempt: 1, + started_at: new Date().toISOString(), + payload: { uuid_tenant: agent.uuid_tenant, telegram_bot_username: agent.telegram_bot_username }, + }) + .select("id") + .single(); + + if (jobErr || !job) { + return jsonResponse(500, { error: "failed to create provisioning_job", detail: jobErr?.message }); + } + + // 4) Decrypt do telegram_bot_token (se existir) + let telegramToken = ""; + if (agent.telegram_bot_token_vault_id) { + const { data: secret } = await supabase.rpc("vault_decrypt_secret", { + secret_id: agent.telegram_bot_token_vault_id, + }); + telegramToken = secret?.[0]?.decrypted_secret ?? ""; + } + + if (!telegramToken) { + await failJob(supabase, agent, job.id, "telegram_bot_token ausente no Vault — usuário precisa concluir onboarding antes"); + return jsonResponse(412, { error: "telegram token missing" }); + } + + // 5) Apagar webhook Telegram (Hermes vai usar polling) + try { + await deleteTelegramWebhook(telegramToken); + } catch (e) { + console.warn("deleteTelegramWebhook failed (continuing):", String(e)); + } + + // 6) Criar serviço no Railway + const serviceName = `mika-${agent.uuid_tenant.replace(/-/g, "").slice(0, 8)}`; + let railwayServiceId: string; + + try { + railwayServiceId = await createRailwayService({ + token: RAILWAY_API_TOKEN, + projectId: pool.railway_project_id, + name: serviceName, + }); + + await configureRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: railwayServiceId, + environmentId: pool.railway_environment_id, + image: "nousresearch/hermes-agent:latest", + variables: { + TELEGRAM_BOT_TOKEN: telegramToken, + TELEGRAM_ALLOWED_USERS: "", + API_SERVER_ENABLED: "false", + HERMES_HOME: "/root/.hermes", + MAIN_MODEL_PROVIDER: "opencode-zen", + OPENCODE_ZEN_API_KEY, + OPENCODE_GO_API_KEY, + HERMES_GATEWAY_CMD: "true", + }, + }); + + await deployRailwayService({ + token: RAILWAY_API_TOKEN, + serviceId: railwayServiceId, + environmentId: pool.railway_environment_id, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.error("Railway provisioning failed:", msg); + await scheduleRetry(supabase, agent, job.id, msg); + return jsonResponse(500, { error: "railway provisioning failed", detail: msg }); + } + + // 7) Persistir railway_service_id no agent_instance e no job + await supabase + .from("agent_instances") + .update({ railway_service_id: railwayServiceId, vps_pool_id: pool.id }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ railway_service_id: railwayServiceId }) + .eq("id", job.id); + + // status permanece 'provisioning' — o railway-webhook atualiza para 'active' quando o deploy subir + return jsonResponse(200, { + success: true, + agent_instance_id: agent.id, + railway_service_id: railwayServiceId, + job_id: job.id, + }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +} + +async function failJob( + supabase: ReturnType, + agent: { id: string }, + jobId: string | null, + message: string, +) { + if (jobId) { + await supabase + .from("provisioning_jobs") + .update({ status: "failed", error_message: message, completed_at: new Date().toISOString() }) + .eq("id", jobId); + } + await supabase.from("agent_instances").update({ status: "error" }).eq("id", agent.id); +} + +async function scheduleRetry( + supabase: ReturnType, + agent: { id: string }, + jobId: string, + message: string, +) { + // Lê a tentativa atual + const { data: job } = await supabase + .from("provisioning_jobs") + .select("attempt, max_attempts") + .eq("id", jobId) + .single(); + + const attempt = job?.attempt ?? 1; + const max = job?.max_attempts ?? 5; + + if (attempt >= max) { + await failJob(supabase, agent, jobId, `Max attempts reached. Last error: ${message}`); + return; + } + + const nextDelayMs = Math.pow(attempt, 2) * 60_000; // attempt^2 minutos + const nextRetryAt = new Date(Date.now() + nextDelayMs).toISOString(); + + await supabase + .from("provisioning_jobs") + .update({ + status: "retrying", + attempt: attempt + 1, + error_message: message, + next_retry_at: nextRetryAt, + }) + .eq("id", jobId); +} diff --git a/supabase/functions/railway-webhook/index.ts b/supabase/functions/railway-webhook/index.ts new file mode 100644 index 0000000..78f96ef --- /dev/null +++ b/supabase/functions/railway-webhook/index.ts @@ -0,0 +1,116 @@ +// railway-webhook (público) +// Recebe eventos do Railway (Project Settings → Webhooks) sobre deploys. +// Quando um deployment SUCCESS bate em um railway_service_id que conhecemos, +// marcamos o agent_instance como 'active'. +// +// Payload Railway (resumido): +// { type: "DEPLOY", deployment: { id, status, serviceId, environmentId, ... }, project, ... } +// Status possíveis: BUILDING, DEPLOYING, SUCCESS, FAILED, CRASHED, REMOVED + +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.4"; +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")!; + +interface RailwayWebhookPayload { + type?: string; + deployment?: { + id?: string; + status?: string; + serviceId?: string; + environmentId?: string; + }; + // Railway envia variantes; aceitamos serviceId/serviço em vários lugares + service?: { id?: string }; + status?: string; +} + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + if (req.method !== "POST") { + return jsonResponse(405, { error: "method not allowed" }); + } + + let payload: RailwayWebhookPayload; + try { + payload = await req.json(); + } catch { + return jsonResponse(400, { error: "invalid json" }); + } + + const serviceId = + payload.deployment?.serviceId ?? payload.service?.id ?? null; + const status = payload.deployment?.status ?? payload.status ?? null; + + if (!serviceId || !status) { + console.log("railway-webhook: payload sem serviceId/status — ignorando", payload); + return jsonResponse(200, { ignored: true }); + } + + const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const { data: agent } = await supabase + .from("agent_instances") + .select("id, status") + .eq("railway_service_id", serviceId) + .maybeSingle(); + + if (!agent) { + console.log(`railway-webhook: serviceId ${serviceId} não corresponde a nenhum agent_instance`); + return jsonResponse(200, { ignored: true }); + } + + const now = new Date().toISOString(); + const upper = status.toUpperCase(); + + if (upper === "SUCCESS") { + await supabase + .from("agent_instances") + .update({ + status: "active", + provisioned_at: now, + last_health_check_at: now, + }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ status: "completed", completed_at: now }) + .eq("agent_instance_id", agent.id) + .in("status", ["running", "retrying"]); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "active" }); + } + + if (upper === "FAILED" || upper === "CRASHED") { + await supabase + .from("agent_instances") + .update({ status: "error" }) + .eq("id", agent.id); + + await supabase + .from("provisioning_jobs") + .update({ + status: "failed", + error_message: `Railway deployment ${upper}`, + completed_at: now, + }) + .eq("agent_instance_id", agent.id) + .in("status", ["running", "retrying"]); + + return jsonResponse(200, { ok: true, agent_id: agent.id, new_status: "error" }); + } + + // BUILDING / DEPLOYING / outros — apenas log + return jsonResponse(200, { ok: true, ignored_status: upper }); +}); + +function jsonResponse(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); +}